From fb37fc40a3a1c57f2ea6ad23c74db4a1d2734490 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Fri, 7 Jul 2023 14:56:43 +1000 Subject: [PATCH] Add protected download route with signed route signatures --- app/Http/Controllers/ExportController.php | 16 ++++-- .../ProtectedDownloadController.php | 41 ++++++++++++++ app/Http/Kernel.php | 4 +- app/Http/Middleware/ValidateSignature.php | 49 +++++++++++++++++ app/Jobs/Company/CompanyExport.php | 54 ++++++++++--------- resources/views/errors/guest.blade.php | 2 +- routes/api.php | 3 ++ 7 files changed, 135 insertions(+), 34 deletions(-) create mode 100644 app/Http/Controllers/ProtectedDownloadController.php create mode 100644 app/Http/Middleware/ValidateSignature.php diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php index ab2f95a253..f459a27333 100644 --- a/app/Http/Controllers/ExportController.php +++ b/app/Http/Controllers/ExportController.php @@ -11,10 +11,12 @@ namespace App\Http\Controllers; -use App\Http\Requests\Export\StoreExportRequest; -use App\Jobs\Company\CompanyExport; -use App\Utils\Traits\MakesHash; +use Illuminate\Support\Str; use Illuminate\Http\Response; +use App\Utils\Traits\MakesHash; +use App\Jobs\Company\CompanyExport; +use Illuminate\Support\Facades\Cache; +use App\Http\Requests\Export\StoreExportRequest; class ExportController extends BaseController { @@ -54,8 +56,12 @@ class ExportController extends BaseController */ public function index(StoreExportRequest $request) { - CompanyExport::dispatch(auth()->user()->getCompany(), auth()->user()); + $hash = Str::uuid(); + $url = \Illuminate\Support\Facades\URL::temporarySignedRoute('protected_download', now()->addHour(), ['hash' => $hash]); + Cache::put($hash, $url, now()->addHour()); - return response()->json(['message' => 'Processing'], 200); + CompanyExport::dispatch(auth()->user()->getCompany(), auth()->user(), $hash); + + return response()->json(['message' => 'Processing', 'url' => $url], 200); } } diff --git a/app/Http/Controllers/ProtectedDownloadController.php b/app/Http/Controllers/ProtectedDownloadController.php new file mode 100644 index 0000000000..452989efda --- /dev/null +++ b/app/Http/Controllers/ProtectedDownloadController.php @@ -0,0 +1,41 @@ +hash); + + if (!$hashed_path) { + throw new SystemError('File no longer available', 404); + abort(404, 'File no longer available'); + } + + UnlinkFile::dispatch(config('filesystems.default'), $hashed_path)->delay(now()->addSeconds(10)); + + return response()->streamDownload(function () use ($hashed_path) { + echo Storage::get($hashed_path); + }, basename($hashed_path), []); + + } + +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index c7fcc7b5ce..f0cf330a16 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -41,6 +41,7 @@ use App\Http\Middleware\VerifyCsrfToken; use App\Http\Middleware\ContactTokenAuth; use Illuminate\Auth\Middleware\Authorize; use App\Http\Middleware\SetDbByCompanyKey; +use App\Http\Middleware\ValidateSignature; use App\Http\Middleware\PasswordProtection; use App\Http\Middleware\ClientPortalEnabled; use App\Http\Middleware\CheckClientExistence; @@ -49,16 +50,13 @@ use Illuminate\Http\Middleware\SetCacheHeaders; use Illuminate\Session\Middleware\StartSession; use App\Http\Middleware\CheckForMaintenanceMode; use App\Http\Middleware\RedirectIfAuthenticated; -use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Foundation\Http\Kernel as HttpKernel; -use Illuminate\Routing\Middleware\ValidateSignature; use Illuminate\Auth\Middleware\EnsureEmailIsVerified; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth; use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; -use Illuminate\Routing\Middleware\ThrottleRequestsWithRedis; use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; class Kernel extends HttpKernel diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php new file mode 100644 index 0000000000..6308b30862 --- /dev/null +++ b/app/Http/Middleware/ValidateSignature.php @@ -0,0 +1,49 @@ + + */ + protected $ignore = [ + 'q' + ]; + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param string|null $relative + * @return \Illuminate\Http\Response + * + * @throws \Illuminate\Routing\Exceptions\InvalidSignatureException + */ + public function handle($request, Closure $next, $relative = null) + { + $ignore = property_exists($this, 'except') ? $this->except : $this->ignore; + + if ($request->hasValidSignatureWhileIgnoring($ignore, $relative !== 'relative')) { + return $next($request); + } + + throw new InvalidSignatureException; + } +} diff --git a/app/Jobs/Company/CompanyExport.php b/app/Jobs/Company/CompanyExport.php index 1b289f344b..b7b4ecb87e 100644 --- a/app/Jobs/Company/CompanyExport.php +++ b/app/Jobs/Company/CompanyExport.php @@ -11,39 +11,39 @@ namespace App\Jobs\Company; -use App\Jobs\Mail\NinjaMailerJob; -use App\Jobs\Mail\NinjaMailerObject; -use App\Libraries\MultiDB; -use App\Mail\DownloadBackup; -use App\Models\Company; -use App\Models\CreditInvitation; -use App\Models\InvoiceInvitation; -use App\Models\PurchaseOrderInvitation; -use App\Models\QuoteInvitation; -use App\Models\RecurringInvoiceInvitation; use App\Models\User; -use App\Models\VendorContact; use App\Utils\Ninja; -use App\Utils\Traits\MakesHash; +use App\Models\Company; +use App\Libraries\MultiDB; +use Illuminate\Support\Str; +use App\Mail\DownloadBackup; +use App\Jobs\Util\UnlinkFile; +use App\Models\VendorContact; use Illuminate\Bus\Queueable; +use App\Models\QuoteInvitation; +use App\Utils\Traits\MakesHash; +use App\Models\CreditInvitation; +use App\Jobs\Mail\NinjaMailerJob; +use App\Models\InvoiceInvitation; +use Illuminate\Support\Facades\App; +use App\Jobs\Mail\NinjaMailerObject; +use Illuminate\Support\Facades\Cache; +use Illuminate\Queue\SerializesModels; +use App\Models\PurchaseOrderInvitation; +use Illuminate\Support\Facades\Storage; +use Illuminate\Queue\InteractsWithQueue; +use App\Models\RecurringInvoiceInvitation; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\App; -use Illuminate\Support\Facades\Storage; class CompanyExport implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, MakesHash; - public $company; - - private $export_format; + private $export_format = 'json'; private $export_data = []; - public $user; /** * Create a new job instance. @@ -52,11 +52,8 @@ class CompanyExport implements ShouldQueue * @param User $user * @param string $custom_token_name */ - public function __construct(Company $company, User $user, $export_format = 'json') + public function __construct(public Company $company, private User $user, public string $hash) { - $this->company = $company; - $this->user = $user; - $this->export_format = $export_format; } /** @@ -467,7 +464,12 @@ class CompanyExport implements ShouldQueue } $storage_file_path = Storage::disk(config('filesystems.default'))->url('backups/'.$file_name); + $storage_path = Storage::disk(config('filesystems.default'))->path('backups/'.$file_name); + + $url = Cache::get($this->hash); + Cache::put($this->hash, $storage_path, now()->addHour()); + App::forgetInstance('translator'); $t = app('translator'); $t->replace(Ninja::transformTranslations($this->company->settings)); @@ -475,12 +477,14 @@ class CompanyExport implements ShouldQueue $company_reference = Company::find($this->company->id); $nmo = new NinjaMailerObject; - $nmo->mailable = new DownloadBackup($storage_file_path, $company_reference); + $nmo->mailable = new DownloadBackup($url, $company_reference); $nmo->to_user = $this->user; $nmo->company = $company_reference; $nmo->settings = $this->company->settings; NinjaMailerJob::dispatch($nmo, true); + + UnlinkFile::dispatch(config('filesystems.default'), $storage_path)->delay(now()->addHours(1)); if (Ninja::isHosted()) { sleep(3); diff --git a/resources/views/errors/guest.blade.php b/resources/views/errors/guest.blade.php index f1cf00ec36..6e99c267b7 100644 --- a/resources/views/errors/guest.blade.php +++ b/resources/views/errors/guest.blade.php @@ -1,5 +1,5 @@ @extends('portal.ninja2020.layout.error') -@section('title', __($title) ?? 'Server Error') +@section('title', $title ?? 'Error') @section('code', __($code) ?? '500') @section('message', __($message) ?? 'System Error') diff --git a/routes/api.php b/routes/api.php index 5d9b20a036..9d1e339db1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -101,6 +101,7 @@ use App\Http\Controllers\Support\Messages\SendingController; use App\Http\Controllers\Reports\ClientSalesReportController; use App\Http\Controllers\Reports\InvoiceItemReportController; use App\Http\Controllers\PaymentNotificationWebhookController; +use App\Http\Controllers\ProtectedDownloadController; use App\Http\Controllers\Reports\ProductSalesReportController; use App\Http\Controllers\Reports\ClientBalanceReportController; use App\Http\Controllers\Reports\ClientContactReportController; @@ -401,4 +402,6 @@ Route::post('api/v1/yodlee/data_updates', [YodleeController::class, 'dataUpdates Route::post('api/v1/yodlee/refresh_updates', [YodleeController::class, 'refreshUpdatesWebhook'])->middleware('throttle:100,1'); Route::post('api/v1/yodlee/balance', [YodleeController::class, 'balanceWebhook'])->middleware('throttle:100,1'); +Route::get('api/v1/protected_download/{hash}', [ProtectedDownloadController::class, 'index'])->name('protected_download')->middleware('signed')->middleware('throttle:300,1'); + Route::fallback([BaseController::class, 'notFound'])->middleware('throttle:404'); \ No newline at end of file