diff --git a/app/Console/Commands/OneTime/MoveAvatars.php b/app/Console/Commands/OneTime/MoveAvatars.php index ed7f83adaed..bc63aa60108 100644 --- a/app/Console/Commands/OneTime/MoveAvatars.php +++ b/app/Console/Commands/OneTime/MoveAvatars.php @@ -96,7 +96,7 @@ private function moveContactAvatars($contact) } if (! $this->option('dryrun')) { $avatarFile = $storage->get($avatarFileName); - $newStorage->put($avatarFileName, $avatarFile, 'public'); + $newStorage->put($avatarFileName, $avatarFile, config('filesystems.default_visibility')); } $this->line(' File pushed: '.$avatarFileName, null, OutputInterface::VERBOSITY_VERBOSE); diff --git a/app/Http/Controllers/ContactsController.php b/app/Http/Controllers/ContactsController.php index d15ada5c1ed..a8c422b04be 100644 --- a/app/Http/Controllers/ContactsController.php +++ b/app/Http/Controllers/ContactsController.php @@ -462,7 +462,10 @@ public function update(Request $request, Contact $contact) } $contact->has_avatar = true; $contact->avatar_location = config('filesystems.default'); - $contact->avatar_file_name = $request->avatar->storePublicly('avatars', $contact->avatar_location); + $contact->avatar_file_name = $request->file('avatar')->store('avatars', [ + 'disk' => $contact->avatar_location, + 'visibility' => config('filesystems.default_visibility'), + ]); $contact->save(); } diff --git a/app/Http/Controllers/StorageController.php b/app/Http/Controllers/StorageController.php new file mode 100644 index 00000000000..b9084e65551 --- /dev/null +++ b/app/Http/Controllers/StorageController.php @@ -0,0 +1,136 @@ +middleware(['setEtag', 'ifMatch', 'ifNoneMatch']); + } + + /** + * Download file with authorization. + * + * @param Request $request + * @param string $file + * @return \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\StreamedResponse|null + */ + public function show(Request $request, string $file) + { + $filename = $this->getFilename($request, $file); + + try { + $disk = StorageHelper::disk(config('filesystems.default')); + + $lastModified = Carbon::createFromTimestamp($disk->lastModified($file), 'UTC')->locale('en'); + + $headers = [ + 'Last-Modified' => $lastModified->isoFormat('ddd\, DD MMM YYYY HH\:mm\:ss \G\M\T'), + 'Cache-Control' => config('filesystems.default_cache_control'), + ]; + + if (! $this->checkConditions($request, $lastModified)) { + return Response::noContent(304, $headers)->setNotModified(); + } + + return $disk->response($file, $filename, $headers); + } catch (FileNotFoundException $e) { + abort(404); + } + } + + /** + * Get the filename for this file. + * + * @param Request $request + * @param string $file + * @return string + */ + private function getFilename(Request $request, string $file): string + { + $accountId = $request->user()->account_id; + $folder = Str::before($file, '/'); + + switch ($folder) { + case 'avatars': + $obj = Contact::where([ + 'account_id' => $accountId, + ['avatar_default_url', 'like', "$file%"], + ])->first(); + $filename = Str::after($file, '/'); + break; + + case 'photos': + $obj = Photo::where([ + 'account_id' => $accountId, + 'new_filename' => $file, + ])->first(); + $filename = $obj ? $obj->original_filename : null; + break; + + case 'documents': + $obj = Document::where([ + 'account_id' => $accountId, + 'new_filename' => $file, + ])->first(); + $filename = $obj ? $obj->original_filename : null; + break; + + default: + $obj = false; + $filename = null; + break; + } + + if ($obj === false || $obj === null || ! $obj->exists) { + abort(404); + } + + return $filename; + } + + /** + * Check for If-Modified-Since and If-Unmodified-Since conditions. + * Return true if the condition does not match. + * + * @param Request $request + * @param Carbon $lastModified Last modified date + * @return bool + */ + private function checkConditions(Request $request, Carbon $lastModified): bool + { + if (! $request->header('If-None-Match') && ($ifModifiedSince = $request->header('If-Modified-Since'))) { + // The If-Modified-Since header contains a date. We will only + // return the entity if it has been changed since that date. + $date = Carbon::parse($ifModifiedSince); + + if ($lastModified->lessThanOrEqualTo($date)) { + return false; + } + } + + if ($ifUnmodifiedSince = $request->header('If-Unmodified-Since')) { + // The If-Unmodified-Since will allow the request if the + // entity has not changed since the specified date. + $date = Carbon::parse($ifUnmodifiedSince); + + // We must only check the date if it's valid + if ($lastModified->greaterThan($date)) { + abort(412, 'An If-Unmodified-Since header was specified, but the entity has been changed since the specified date.'); + } + } + + return true; + } +} diff --git a/app/Jobs/Avatars/MoveContactAvatarToPhotosDirectory.php b/app/Jobs/Avatars/MoveContactAvatarToPhotosDirectory.php index e963ea9c909..fbfff88f2f9 100644 --- a/app/Jobs/Avatars/MoveContactAvatarToPhotosDirectory.php +++ b/app/Jobs/Avatars/MoveContactAvatarToPhotosDirectory.php @@ -103,7 +103,7 @@ private function moveContactAvatars(): ?string if (! $this->dryrun) { $avatarFile = $this->storage->get($avatarFileName); - $newStorage->put($newAvatarFilename, $avatarFile, 'public'); + $newStorage->put($newAvatarFilename, $avatarFile, config('filesystems.default_visibility')); $this->contact->avatar_location = config('filesystems.default'); $this->contact->save(); diff --git a/app/Jobs/ResizeAvatars.php b/app/Jobs/ResizeAvatars.php index 2c95943ea9e..0fad5c8736f 100644 --- a/app/Jobs/ResizeAvatars.php +++ b/app/Jobs/ResizeAvatars.php @@ -63,6 +63,6 @@ private function resize($avatarFile, $filename, $extension, $storage, $size) $avatar = Image::make($avatarFile); $avatar->fit($size); - $storage->put($avatarFileName, (string) $avatar->stream(), 'public'); + $storage->put($avatarFileName, (string) $avatar->stream(), config('filesystems.default_visibility')); } } diff --git a/app/Models/Account/Photo.php b/app/Models/Account/Photo.php index e45355627f0..add8c53d54c 100644 --- a/app/Models/Account/Photo.php +++ b/app/Models/Account/Photo.php @@ -64,9 +64,11 @@ public function contact() */ public function url() { - $url = $this->new_filename; + if (config('filesystems.default_visibility') === 'public') { + return asset(StorageHelper::disk(config('filesystems.default'))->url($this->new_filename)); + } - return asset(StorageHelper::disk(config('filesystems.default'))->url($url)); + return route('storage', ['file' => $this->new_filename]); } /** diff --git a/app/Models/Contact/Contact.php b/app/Models/Contact/Contact.php index 6d1764badc9..ffa31ee4bb9 100644 --- a/app/Models/Contact/Contact.php +++ b/app/Models/Contact/Contact.php @@ -8,7 +8,6 @@ use App\Helpers\LocaleHelper; use App\Models\Account\Photo; use App\Models\Journal\Entry; -use function Safe\preg_split; use App\Helpers\StorageHelper; use App\Helpers\WeatherHelper; use Illuminate\Support\Carbon; @@ -1049,17 +1048,18 @@ public function getAvatarDefaultURL() return ''; } - try { - $matches = preg_split('/\?/', $this->avatar_default_url); + if (config('filesystems.default_visibility') === 'public') { + $matches = Str::of($this->avatar_default_url)->split('/\?/'); + $url = asset(StorageHelper::disk(config('filesystems.default'))->url($matches[0])); - if (count($matches) > 1) { + if ($matches->count() > 1) { $url .= '?'.$matches[1]; } return $url; - } catch (\Exception $e) { - return ''; } + + return route('storage', ['file' => $this->avatar_default_url]); } /** diff --git a/app/Models/Contact/Document.php b/app/Models/Contact/Document.php index b4c19f1e872..d53ceb83f19 100644 --- a/app/Models/Contact/Document.php +++ b/app/Models/Contact/Document.php @@ -59,8 +59,10 @@ public function contact() */ public function getDownloadLink(): string { - $url = $this->new_filename; + if (config('filesystems.default_visibility') === 'public') { + return asset(StorageHelper::disk(config('filesystems.default'))->url($this->new_filename)); + } - return asset(StorageHelper::disk(config('filesystems.default'))->url($url)); + return route('storage', ['file' => $this->new_filename]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d7fd869f6ba..6c3e772f029 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,11 +10,13 @@ use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\View; use App\Notifications\EmailMessaging; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Auth\Notifications\VerifyEmail; +use Werk365\EtagConditionals\EtagConditionals; use Illuminate\Auth\Notifications\ResetPassword; class AppServiceProvider extends ServiceProvider @@ -74,6 +76,14 @@ public function boot() Limit::perDay(5000), ]; }); + + EtagConditionals::etagGenerateUsing(function (\Illuminate\Http\Request $request, \Symfony\Component\HttpFoundation\Response $response) { + $url = $request->getRequestUri(); + + return Cache::rememberForever('etag.'.$url, function () use ($url) { + return md5($url); + }); + }); } /** diff --git a/app/Services/Account/Photo/UploadPhoto.php b/app/Services/Account/Photo/UploadPhoto.php index 6693dc8e342..a9b58a9c740 100644 --- a/app/Services/Account/Photo/UploadPhoto.php +++ b/app/Services/Account/Photo/UploadPhoto.php @@ -84,7 +84,10 @@ private function importPhoto($data): array 'original_filename' => $photo->getClientOriginalName(), 'filesize' => $photo->getSize(), 'mime_type' => (new \Mimey\MimeTypes)->getMimeType($photo->guessClientExtension()), - 'new_filename' => $photo->storePublicly('photos', config('filesystems.default')), + 'new_filename' => $photo->store('photos', [ + 'disk' => config('filesystems.default'), + 'visibility' => config('filesystems.default_visibility'), + ]), ]; } @@ -146,7 +149,7 @@ private function importFile(array $data): ?array private function storeImage(string $disk, $image, string $filename): ?string { $result = Storage::disk($disk) - ->put($path = $filename, (string) $image->stream(), 'public'); + ->put($path = $filename, (string) $image->stream(), config('filesystems.default_visibility')); return $result ? $path : null; } diff --git a/app/Services/Contact/Avatar/GenerateDefaultAvatar.php b/app/Services/Contact/Avatar/GenerateDefaultAvatar.php index e66ca3d9aa0..bd277bc7421 100644 --- a/app/Services/Contact/Avatar/GenerateDefaultAvatar.php +++ b/app/Services/Contact/Avatar/GenerateDefaultAvatar.php @@ -5,6 +5,7 @@ use Illuminate\Support\Str; use App\Services\BaseService; use App\Models\Contact\Contact; +use Illuminate\Support\Facades\Cache; use Laravolt\Avatar\Facade as Avatar; use Illuminate\Support\Facades\Storage; use Illuminate\Contracts\Filesystem\FileNotFoundException; @@ -47,6 +48,8 @@ public function execute(array $data) $contact->avatar_default_url = $filename; $contact->save(); + Cache::forget('etag'.Str::before('?', $filename)); + return $contact; } @@ -83,7 +86,7 @@ private function createNewAvatar(Contact $contact) $filename = 'avatars/'.$contact->uuid.'.jpg'; Storage::disk(config('filesystems.default')) - ->put($filename, $img, 'public'); + ->put($filename, $img, config('filesystems.default_visibility')); // This will force the browser to reload the new avatar return $filename.'?'.now()->format('U'); diff --git a/app/Services/Contact/Document/UploadDocument.php b/app/Services/Contact/Document/UploadDocument.php index 52b1cecc810..582ca842a95 100644 --- a/app/Services/Contact/Document/UploadDocument.php +++ b/app/Services/Contact/Document/UploadDocument.php @@ -65,7 +65,10 @@ private function populateData($data) 'mime_type' => (new \Mimey\MimeTypes)->getMimeType($document->guessClientExtension()), ]; - $filename = $document->storePublicly('documents', config('filesystems.default')); + $filename = $document->store('documents', [ + 'disk' => config('filesystems.default'), + 'visibility' => config('filesystems.default_visibility'), + ]); return array_merge($data, [ 'new_filename' => $filename, diff --git a/composer.json b/composer.json index e70172ec0d9..4e267a1605c 100644 --- a/composer.json +++ b/composer.json @@ -57,6 +57,7 @@ "web-token/jwt-signature-algorithm-ecdsa": "^2.1", "web-token/jwt-signature-algorithm-eddsa": "^2.1", "web-token/jwt-signature-algorithm-rsa": "^2.1", + "werk365/etagconditionals": "^1.2", "xantios/mimey": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 745051564d6..e52d562a57c 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": "6e935211274a98900df063306a02f2d2", + "content-hash": "c0c0b45e6b8264564db678658a42159c", "packages": [ { "name": "asbiin/laravel-webauthn", @@ -12575,6 +12575,66 @@ }, "time": "2021-03-09T10:59:23+00:00" }, + { + "name": "werk365/etagconditionals", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/365Werk/etagconditionals.git", + "reference": "947743c7b711a0669d1dd39f4e8797b62159dcb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/365Werk/etagconditionals/zipball/947743c7b711a0669d1dd39f4e8797b62159dcb8", + "reference": "947743c7b711a0669d1dd39f4e8797b62159dcb8", + "shasum": "" + }, + "require": { + "illuminate/support": "~7|~8" + }, + "require-dev": { + "orchestra/testbench": "~5|~6", + "phpunit/phpunit": "~8.0|~9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Werk365\\EtagConditionals\\EtagConditionalsServiceProvider" + ], + "aliases": { + "EtagConditionals": "Werk365\\EtagConditionals\\Facades\\EtagConditionals" + } + } + }, + "autoload": { + "psr-4": { + "Werk365\\EtagConditionals\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Hergen Dillema", + "email": "h.dillema@365werk.nl", + "homepage": "https://365werk.nl" + } + ], + "description": "Laravel package to enable support for ETags and handling If-Match and If-None-Match conditional requests", + "homepage": "https://github.com/werk365/etagconditionals", + "keywords": [ + "EtagConditionals", + "laravel" + ], + "support": { + "issues": "https://github.com/365Werk/etagconditionals/issues", + "source": "https://github.com/365Werk/etagconditionals/tree/1.2.0" + }, + "time": "2021-06-03T09:16:46+00:00" + }, { "name": "xantios/mimey", "version": "v2.2.0", @@ -15957,7 +16017,7 @@ "barrelstrength/sprout-forms": "<3.9", "baserproject/basercms": ">=4,<=4.3.6|>=4.4,<4.4.1", "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", - "bolt/bolt": "<3.7.1", + "bolt/bolt": "<3.7.2", "bolt/core": "<4.1.13", "brightlocal/phpwhois": "<=4.2.5", "buddypress/buddypress": "<5.1.2", @@ -15987,8 +16047,9 @@ "doctrine/orm": ">=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", "dolibarr/dolibarr": "<11.0.4", "dompdf/dompdf": ">=0.6,<0.6.2", - "drupal/core": ">=7,<7.74|>=8,<8.8.11|>=8.9,<8.9.9|>=9,<9.0.8", - "drupal/drupal": ">=7,<7.74|>=8,<8.8.11|>=8.9,<8.9.9|>=9,<9.0.8", + "drupal/core": ">=7,<7.80|>=8,<8.9.14|>=9,<9.0.12|>=9.1,<9.1.7", + "drupal/drupal": ">=7,<7.80|>=8,<8.9.14|>=9,<9.0.12|>=9.1,<9.1.7", + "dweeves/magmi": "<=0.7.24", "endroid/qr-code-bundle": "<3.4.2", "enshrined/svg-sanitize": "<0.13.1", "erusev/parsedown": "<1.7.2", @@ -16013,7 +16074,9 @@ "flarum/tags": "<=0.1-beta.13", "fluidtypo3/vhs": "<5.1.1", "fooman/tcpdf": "<6.2.22", + "forkcms/forkcms": "<5.8.3", "fossar/tcpdf-parser": "<6.2.22", + "francoisjacquet/rosariosis": "<6.5.1", "friendsofsymfony/oauth2-php": "<1.3", "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2", "friendsofsymfony/user-bundle": ">=1.2,<1.3.5", @@ -16044,7 +16107,8 @@ "laravel/framework": "<6.20.26|>=7,<8.40", "laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10", "league/commonmark": "<0.18.3", - "librenms/librenms": "<1.53", + "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", + "librenms/librenms": "<21.1", "livewire/livewire": ">2.2.4,<2.2.6", "magento/community-edition": ">=2,<2.2.10|>=2.3,<2.3.3", "magento/magento1ce": "<1.9.4.3", @@ -16065,11 +16129,12 @@ "nystudio107/craft-seomatic": "<3.3", "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", "october/backend": "<1.1.2", - "october/cms": "= 1.0.469|>=1.0.319,<1.0.469", + "october/cms": "= 1.1.1|= 1.0.471|= 1.0.469|>=1.0.319,<1.0.469", "october/october": ">=1.0.319,<1.0.466", "october/rain": "<1.0.472|>=1.1,<1.1.2", "onelogin/php-saml": "<2.10.4", "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5", + "opencart/opencart": "<=3.0.3.2", "openid/php-openid": "<2.3", "openmage/magento-lts": "<=19.4.12|>=20,<=20.0.8", "orchid/platform": ">=9,<9.4.4", @@ -16163,20 +16228,21 @@ "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7", "symfony/http-kernel": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.4.13|>=5,<5.1.5", "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", + "symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1", "symfony/mime": ">=4.3,<4.3.8", "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", "symfony/polyfill": ">=1,<1.10", "symfony/polyfill-php55": ">=1,<1.10", "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", "symfony/routing": ">=2,<2.0.19", - "symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=4.4,<4.4.7|>=5,<5.0.7", + "symfony/security": ">=2,<2.7.51|>=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", "symfony/security-bundle": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", - "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<2.8.37|>=3,<3.3.17|>=3.4,<3.4.7|>=4,<4.0.7", + "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", - "symfony/security-guard": ">=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", - "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7", + "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", + "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", "symfony/serializer": ">=2,<2.0.11", - "symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.4.13|>=5,<5.1.5", + "symfony/symfony": ">=2,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", "symfony/translation": ">=2,<2.0.17", "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3", "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", @@ -16241,7 +16307,8 @@ "zetacomponents/mail": "<1.8.2", "zf-commons/zfc-user": "<1.2.2", "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", - "zfr/zfr-oauth2-server-module": "<0.1.2" + "zfr/zfr-oauth2-server-module": "<0.1.2", + "zoujingli/thinkadmin": "<6.0.22" }, "type": "metapackage", "notification-url": "https://packagist.org/downloads/", diff --git a/config/filesystems.php b/config/filesystems.php index c38fa17cac1..e7e6da339a2 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -86,4 +86,36 @@ public_path('storage') => storage_path('app/public'), ], + /* + |-------------------------------------------------------------------------- + | Filesystem visibility + |-------------------------------------------------------------------------- + | + | If this is set to private, all files are stored privately, and are + | delivered using a proxy-url by monica, providing access of the files in + | the storage. + | This means only the authenticated user will be able to open the files. + | + | You might store the files publicly if you're on a private instance and if + | you want to make files accessible from the outside - the url files are + | still private and not easy to guess. + | + | Supported: "private", "public" + | + */ + + 'default_visibility' => env('FILESYSTEM_DEFAULT_VISIBILITY', 'private'), + + /* + |-------------------------------------------------------------------------- + | Cache control for files + |-------------------------------------------------------------------------- + | + | Defines the Cache-Control header used to serve files. + | Default: 'max-age=2628000' for 1 month cache. + | + */ + + 'default_cache_control' => env('DEFAULT_CACHE_CONTROL', 'private, max-age=2628000'), + ]; diff --git a/database/migrations/2020_04_24_212138_update_amount_format.php b/database/migrations/2020_04_24_212138_update_amount_format.php index a46d7ec04ff..0d3323d2de7 100644 --- a/database/migrations/2020_04_24_212138_update_amount_format.php +++ b/database/migrations/2020_04_24_212138_update_amount_format.php @@ -2,6 +2,7 @@ use App\Helpers\MoneyHelper; use App\Models\Account\Account; +use Illuminate\Support\Facades\DB; use Money\Currencies\ISOCurrencies; use Money\Currency as MoneyCurrency; use Illuminate\Support\Facades\Schema; diff --git a/package.json b/package.json index 07a5787c73f..55f23e39e07 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "hot": "mix watch --hot", "prod": "yarn production", "production": "mix --production", + "predevelopment": "php artisan lang:generate -vvv", + "prewatch": "php artisan lang:generate -vvv", "preproduction": "php artisan lang:generate -vvv", "heroku-postbuild": "yarn run production", "e2e": "cypress run", diff --git a/routes/web.php b/routes/web.php index f91929a30b0..0a0db84c9a2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,6 +46,8 @@ Route::post('/dashboard/setTab', 'DashboardController@setTab'); }); + Route::get('/store/{file}', 'StorageController@show')->where('file', '.*')->name('storage'); + Route::get('/compliance', 'ComplianceController@index')->name('compliance'); Route::post('/compliance/sign', 'ComplianceController@store'); Route::get('/changelog', 'ChangelogController@index')->name('changelog.index'); diff --git a/scripts/.htaccess_production b/scripts/.htaccess_production index 1854fefa1b2..324b8189649 100644 --- a/scripts/.htaccess_production +++ b/scripts/.htaccess_production @@ -11,7 +11,7 @@ # Activate HSTS - Header always set Strict-Transport-Security "max-age=15768000; includeSubDomains; preload;" + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload;" Header always set Referrer-Policy "no-referrer" Header always set X-Content-Type-Options "nosniff" @@ -22,19 +22,8 @@ Header always set X-XSS-Protection "1; mode=block" # Assets expire after 1 month - - Header set Cache-Control "public, max-age=15768000" - - # Activate HSTS - Header always set Strict-Transport-Security "max-age=15768000; includeSubDomains; preload;" - - Header always set Referrer-Policy "no-referrer" - Header always set X-Content-Type-Options "nosniff" - Header always set X-Download-Options "noopen" - Header always set X-Frame-Options "SAMEORIGIN" - Header always set X-Permitted-Cross-Domain-Policies "none" - Header always set X-Robots-Tag "none" - Header always set X-XSS-Protection "1; mode=block" + + Header set Cache-Control "public, max-age=2628000" diff --git a/tests/Feature/StorageControllerTest.php b/tests/Feature/StorageControllerTest.php new file mode 100644 index 00000000000..7847ff873df --- /dev/null +++ b/tests/Feature/StorageControllerTest.php @@ -0,0 +1,374 @@ +signIn(); + + $contact = factory(Contact::class)->create([ + 'account_id' => $user->account_id, + ]); + + return [$user, $contact]; + } + + /** @test */ + public function it_get_photo_content() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $response = $this->get('/store/'.$file); + + $response->assertStatus(200); + $response->assertHeader('Last-Modified', 'Sat, 19 Jun 2021 07:00:00 GMT'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + /** @test */ + public function it_get_avatar_content() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeAvatar($contact); + + $response = $this->get('/store/'.$file); + + $response->assertStatus(200); + $response->assertHeader('Last-Modified', 'Sat, 19 Jun 2021 07:00:00 GMT'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + /** @test */ + public function it_get_document_content() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeDocument($contact); + + $response = $this->get('/store/'.$file); + + $response->assertStatus(200); + $response->assertHeader('Last-Modified', 'Sat, 19 Jun 2021 07:00:00 GMT'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + /** @test */ + public function it_returns_404_if_avatar_not_exist() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $response = $this->get('/store/avatars/test'); + + $response->assertStatus(404); + } + + /** @test */ + public function it_returns_404_if_folder_unknown() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $response = $this->get('/store/xxx/test'); + + $response->assertStatus(404); + } + + /** @test */ + public function it_returns_200_if_modified_after_IfModifiedSince() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $response = $this->get('/store/'.$file, [ + 'If-Modified-Since' => 'Sat, 12 Jun 2021 07:00:00 GMT', + ]); + + $response->assertStatus(200); + $response->assertHeader('Last-Modified', 'Sat, 19 Jun 2021 07:00:00 GMT'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + /** @test */ + public function it_returns_304_if_not_modified_since_IfModifiedSince() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $response = $this->get('/store/'.$file, [ + 'If-Modified-Since' => 'Sat, 26 Jun 2021 07:00:00 GMT', + ]); + + $response->assertNoContent(304); + $response->assertHeaderMissing('Last-Modified'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + /** @test */ + public function it_returns_200_if_not_modified_after_IfUnmodifiedSince() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $response = $this->get('/store/'.$file, [ + 'If-Unmodified-Since' => 'Sat, 26 Jun 2021 07:00:00 GMT', + ]); + + $response->assertStatus(200); + $response->assertHeader('Last-Modified', 'Sat, 19 Jun 2021 07:00:00 GMT'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + /** @test */ + public function it_returns_412_if_modified_after_IfUnmodifiedSince() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $response = $this->get('/store/'.$file, [ + 'If-Unmodified-Since' => 'Sat, 12 Jun 2021 07:00:00 GMT', + ]); + + $response->assertStatus(412); + } + + /** @test */ + public function it_fails_if_file_not_found() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $response = $this->get('/store/photos/fail.png'); + + $response->assertStatus(404); + } + + /** @test */ + public function it_fails_if_file_not_exist() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $photo = factory(Photo::class)->create([ + 'account_id' => $contact->account_id, + 'original_filename' => 'avatar.png', + 'filesize' => 0, + 'mime_type' => '', + 'new_filename' => 'avatar.png', + ]); + + $contact->photos()->syncWithoutDetaching([$photo->id]); + + $response = $this->get('/store/photos/avatar.png'); + + $response->assertStatus(404); + } + + /** @test */ + public function it_fails_if_file_not_owned_by_user() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $this->signIn(); + + $response = $this->get('/store/'.$file, [ + 'If-Unmodified-Since' => 'Sat, 12 Jun 2021 07:00:00 GMT', + ]); + + $response->assertStatus(404); + } + + /** @test */ + public function it_returns_200_if_matching_IfMatch() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $response = $this->get('/store/'.$file, [ + 'If-Match' => '"'.md5('/store/'.$file).'"', + ]); + + $response->assertNoContent(200); + $response->assertHeader('Last-Modified', 'Sat, 19 Jun 2021 07:00:00 GMT'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + /** @test */ + public function it_returns_200_with_none_matching_IfNoneMatch() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $response = $this->get('/store/'.$file, [ + 'If-None-Match' => '"test"', + ]); + + $response->assertNoContent(200); + $response->assertHeader('Last-Modified', 'Sat, 19 Jun 2021 07:00:00 GMT'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + /** @test */ + public function it_returns_304_if_matching_IfNoneMatch() + { + config(['filesystems.default' => 'local']); + + [$user, $contact] = $this->fetchUser(); + + $file = $this->storeImage($contact); + + $response = $this->get('/store/'.$file, [ + 'If-None-Match' => '"'.md5('/store/'.$file).'"', + ]); + + $response->assertNoContent(304); + $response->assertHeaderMissing('Last-Modified'); + $response->assertHeader('Cache-Control', 'max-age=2628000, private'); + $response->assertHeader('etag', '"'.md5('/store/'.$file).'"'); + } + + public function storeImage(Contact $contact) + { + $disk = Storage::fake('local', [ + 'cache' => [ + 'store' => 'file', + 'expire' => 600, + 'prefix' => 'local', + ], + ]); + $image = File::createWithContent('avatar.png', file_get_contents(base_path('public/img/favicon.png'))); + + $file = $disk->put('/photos', $image, 'private'); + + $photo = factory(Photo::class)->create([ + 'account_id' => $contact->account_id, + 'original_filename' => 'avatar.png', + 'filesize' => $image->getSize(), + 'mime_type' => $image->getMimeType(), + 'new_filename' => $file, + ]); + + $contact->photos()->syncWithoutDetaching([$photo->id]); + + $adapter = $disk->getDriver()->getAdapter(); + $adapter->getCache()->updateObject($file, [ + 'timestamp' => Carbon::create(2021, 6, 19, 7, 0, 0, 'UTC')->timestamp, + ]); + + return $file; + } + + public function storeDocument(Contact $contact) + { + $disk = Storage::fake('local', [ + 'cache' => [ + 'store' => 'file', + 'expire' => 600, + 'prefix' => 'local', + ], + ]); + $image = File::createWithContent('file.png', file_get_contents(base_path('public/img/favicon.png'))); + + $file = $disk->put('/documents', $image, 'private'); + + $document = factory(Document::class)->create([ + 'account_id' => $contact->account_id, + 'contact_id' => $contact->id, + 'original_filename' => 'file.png', + 'new_filename' => $file, + ]); + + $adapter = $disk->getDriver()->getAdapter(); + $adapter->getCache()->updateObject($file, [ + 'timestamp' => Carbon::create(2021, 6, 19, 7, 0, 0, 'UTC')->timestamp, + ]); + + return $file; + } + + public function storeAvatar(Contact $contact) + { + $disk = Storage::fake('local', [ + 'cache' => [ + 'store' => 'file', + 'expire' => 600, + 'prefix' => 'local', + ], + ]); + $image = File::createWithContent('avatar.png', file_get_contents(base_path('public/img/favicon.png'))); + + $file = $disk->put('/avatars', $image, 'private'); + + $contact->avatar_source = 'default'; + $contact->avatar_default_url = $file.'?123'; + $contact->save(); + + $adapter = $disk->getDriver()->getAdapter(); + $adapter->getCache()->updateObject($file, [ + 'timestamp' => Carbon::create(2021, 6, 19, 7, 0, 0, 'UTC')->timestamp, + ]); + + return $file; + } +} diff --git a/tests/Unit/Models/ContactTest.php b/tests/Unit/Models/ContactTest.php index e7f74e9346a..9f5747e8879 100644 --- a/tests/Unit/Models/ContactTest.php +++ b/tests/Unit/Models/ContactTest.php @@ -524,7 +524,7 @@ public function it_returns_the_url_of_the_avatar() ]); $this->assertStringContainsString( - 'storage/defaultURL', + 'store/defaultURL', $contact->getAvatarURL() ); @@ -559,7 +559,7 @@ public function it_returns_the_url_of_the_avatar() $contact->save(); $this->assertEquals( - config('app.url').'/storage/'.$photo->new_filename, + config('app.url').'/store/'.$photo->new_filename, $contact->getAvatarURL() ); } diff --git a/tests/Unit/Models/DocumentTest.php b/tests/Unit/Models/DocumentTest.php index 43141bc4e0e..6ba09d658fc 100644 --- a/tests/Unit/Models/DocumentTest.php +++ b/tests/Unit/Models/DocumentTest.php @@ -40,7 +40,7 @@ public function it_gets_the_download_link() $document = factory(Document::class)->create(); $this->assertEquals( - config('app.url').'/storage/'.$document->new_filename, + config('app.url').'/store/'.$document->new_filename, $document->getDownloadLink() ); } diff --git a/tests/Unit/Models/PhotoTest.php b/tests/Unit/Models/PhotoTest.php index 6d033e95559..782f1595cea 100644 --- a/tests/Unit/Models/PhotoTest.php +++ b/tests/Unit/Models/PhotoTest.php @@ -40,7 +40,7 @@ public function it_gets_the_url() { $photo = factory(Photo::class)->create(); $this->assertEquals( - config('app.url').'/storage/'.$photo->new_filename, + config('app.url').'/store/'.$photo->new_filename, $photo->url() ); }