Repository navigation
fix: serve commit images straight from cloud storage #256
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: production
Are you sure you want to change the base?
Changes from all commits
d149c98
1d88500
a0021dd
89e9cf2
11a2d93
39751f5
54b9c80
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Actions; | ||
|
|
||
| use App\Models\CommitImage; | ||
| use Aws\Credentials\CredentialsInterface; | ||
| use Illuminate\Filesystem\AwsS3V3Adapter; | ||
| use Illuminate\Support\Facades\Storage; | ||
|
|
||
| final readonly class CreateCommitImageSignedUrl | ||
| { | ||
| /** | ||
| * How long the URL lets anyone holding it fetch the image, which bounds how long an image stays reachable after its commit is deleted or rejected. | ||
| */ | ||
| public const int LIFETIME = 1200; | ||
|
|
||
| /** | ||
| * How long the browser reuses the URL, and so the image behind it, kept well inside its lifetime. | ||
| */ | ||
| public const int MAX_AGE = 600; | ||
|
|
||
| /** | ||
| * Only cloud storage serves files itself, and only while the credentials signing the URL outlive it: role credentials can expire first, which would leave the browser holding a dead URL. | ||
| */ | ||
| public function handle(CommitImage $commitImage, bool $download = false): ?string | ||
| { | ||
| $disk = Storage::disk(); | ||
|
|
||
| if (! $disk instanceof AwsS3V3Adapter) { | ||
| return null; | ||
| } | ||
|
|
||
| $expires = now()->addSeconds(self::LIFETIME); | ||
| $credentials = $disk->getClient()->getCredentials()->wait(); | ||
|
|
||
| if (! $credentials instanceof CredentialsInterface || ($credentials->getExpiration() !== null && $credentials->getExpiration() < $expires->getTimestamp())) { | ||
| return null; | ||
| } | ||
|
|
||
| return $disk->temporaryUrl($commitImage->path, $expires, [ | ||
| 'ResponseContentType' => 'image/webp', | ||
| 'ResponseContentDisposition' => $download | ||
| ? 'attachment; filename="'.$commitImage->id.'.webp"' | ||
| : 'inline', | ||
| 'ResponseCacheControl' => 'private, max-age='.self::MAX_AGE, | ||
| ]); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
|
|
||
| namespace App\Http\Requests; | ||
|
|
||
| use App\Actions\CreateCommitImageSignedUrl; | ||
| use App\Models\Commit; | ||
| use App\Models\CommitImage; | ||
| use Illuminate\Auth\Access\Response; | ||
|
|
@@ -12,18 +13,29 @@ | |
|
|
||
| final class ShowCommitImageRequest extends FormRequest | ||
| { | ||
| private ?string $signedUrl = null; | ||
|
|
||
| /** | ||
| * Pending and rejected images never leave the server for anyone but their author, the members of its organization | ||
| * and moderators, or the temporary URL the API gave one of them, until the commit is deleted. | ||
| * Pending and rejected images never leave the server for anyone but their author and moderators, or the temporary | ||
| * URL the API gave one of them, until the commit is deleted. So only images anyone may see get a signed storage URL that works without this check. | ||
| * Cloud storage answers a missing file behind a signed URL itself, so storage is only asked about files streamed from here. | ||
| */ | ||
| public function authorize(): Response | ||
| public function authorize(CreateCommitImageSignedUrl $action): Response | ||
| { | ||
| $commitImage = $this->route('commitImage'); | ||
| assert($commitImage instanceof CommitImage); | ||
|
|
||
| $commit = Commit::query()->find($commitImage->commit_id); | ||
|
|
||
| return $commit instanceof Commit && ($commit->imagesAreVisibleTo($this->user()) || (! $commit->trashed() && $this->hasValidSignature())) && Storage::disk()->exists($commitImage->path) | ||
| if (! $commit instanceof Commit || ! ($commit->imagesAreVisibleTo($this->user()) || (! $commit->trashed() && $this->hasValidSignature()))) { | ||
| return Response::denyAsNotFound(); | ||
| } | ||
|
|
||
| $this->signedUrl = $commit->imagesAreVisibleTo(null) | ||
| ? $action->handle($commitImage, $this->download()) | ||
|
Comment on lines
+34
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security Misconfiguration Reachability: External Reject HTTP signed URLs before authorizing the redirect. If an S3-compatible disk returns an HTTP temporary URL,
|
||
| : null; | ||
|
|
||
| return $this->signedUrl !== null || Storage::disk()->exists($commitImage->path) | ||
| ? Response::allow() | ||
| : Response::denyAsNotFound(); | ||
| } | ||
|
|
@@ -36,6 +48,17 @@ public function rules(): array | |
| return []; | ||
| } | ||
|
|
||
| /** | ||
| * Where the image can be fetched straight from cloud storage, if it can. | ||
| */ | ||
| public function signedUrl(): ?string | ||
| { | ||
| return $this->signedUrl; | ||
| } | ||
|
|
||
| /** | ||
| * Whether the image is wanted as a file to save rather than shown in the page. | ||
| */ | ||
| public function download(): bool | ||
| { | ||
| return $this->boolean('download'); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.