S3 & Private Object via ACLs
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeHead to /admin/article
and log back in since we cleared our database recently: admin1@thespacebar.com
, password engage
. Edit any of the articles. Everything should work just fine: I'll select a few references to upload and... it works nicely. It is a bit slower now that the server is sending the files to S3 in the background, though that should be less noticeable once we're on production, especially if our server is also hosted on AWS.
So... can we download these? Try it! Yea, it works great! Open up ArticleReferenceAdminController
and search for "download". Here it is: the download is handled by downloadArticleReference
: we open a file stream from Flysystem - which is now from S3 - and stream that back to the user. By planning ahead and using Flysystem, when we switched to S3, nothing had to change!
But, there is one tiny problem. Back on the page, click the image. Access denied!? This should show us the full-size, original image. Hmm, the URL looks right. And, indeed! The problem isn't the path, the problem is with that file's permissions on S3.
Each file, or "object" on S3 can be set to be publicly accessible or private. File are private by default. In fact, the only reason that we can see the thumbnails, which are also stored in S3... is that LiipImagineBundle is smart enough to make sure that when it saves the files to S3, it saves them as public.
When an author uploads an article image, we need to do the same thing: we do want the original images to be public.
Giving the Images Public ACL
Head over to UploaderHelper
and find uploadFile()
. So far, we've been using the $isPublic
argument to choose between the public and private filesystem objects. But when we changed to S3, I temporarily made these two filesystems identical. That wasn't on accident: with S3, we don't need two filesystems anymore! We can use the same one for both public and private files, and control the visibility on a file-by-file basis.
Check it out: remove the $filesystem =
part and always use $this->filesystem
.
// ... lines 1 - 13 | |
class UploaderHelper | |
{ | |
// ... lines 16 - 108 | |
$newFilename = Urlizer::urlize(pathinfo($originalFilename, PATHINFO_FILENAME)).'-'.uniqid().'.'.$file->guessExtension(); | |
$stream = fopen($file->getPathname(), 'r'); | |
$result = $this->filesystem->writeStream( | |
// ... lines 113 - 117 | |
); | |
// ... lines 119 - 129 | |
} |
To tell Flysystem that a file should be public or private, add a third argument to writeStream()
: an array of options. The option we want is visibility
. If $isPublic
is true, use AdapterInterface
- the one from Flysystem
- ::VISIBILITY_PUBLIC
. Otherwise, AdapterInterface::VISIBILITY_PRIVATE
.
// ... lines 1 - 5 | |
use League\Flysystem\AdapterInterface; | |
// ... lines 7 - 13 | |
class UploaderHelper | |
{ | |
// ... lines 16 - 108 | |
$newFilename = Urlizer::urlize(pathinfo($originalFilename, PATHINFO_FILENAME)).'-'.uniqid().'.'.$file->guessExtension(); | |
$stream = fopen($file->getPathname(), 'r'); | |
$result = $this->filesystem->writeStream( | |
$directory.'/'.$newFilename, | |
$stream, | |
[ | |
'visibility' => $isPublic ? AdapterInterface::VISIBILITY_PUBLIC : AdapterInterface::VISIBILITY_PRIVATE | |
] | |
); | |
// ... lines 119 - 129 | |
} |
Cool, right? That won't instantly change the permissions on the files we've already uploaded. So let's go upload a new one. Close the tab, select a new file, how about rocket.jpg
and... update! The thumbnail still works and if you click it, yes! The original file is public!
By the way, you can see this setting when you're looking at the individual files in S3. Click back to the root of the bucket, find the rocket.jpg
file and click it. Under "Permissions", here we go. My account has all permissions, of course, and under "Public Access", Everyone has "Read object" access.
Remove that Extra Private Filesystem!
Hey! This is awesome! Thanks to the object-by-object permissions super-power of S3, we don't need an extra "private" filesystem at all! We can do some serious cleanup! Start in config/packages/oneup_flysystem.yaml
: remove the private_uploads_adapter
and filesystem.
# Read the documentation: https://github.com/1up-lab/OneupFlysystemBundle/tree/master/Resources/doc/index.md | |
oneup_flysystem: | |
adapters: | |
public_uploads_adapter: | |
awss3v3: | |
client: Aws\S3\S3Client | |
bucket: '%env(AWS_S3_BUCKET_NAME)%' | |
filesystems: | |
public_uploads_filesystem: | |
adapter: public_uploads_adapter |
Next, in services.yaml
, because there's no private_upload_filesystem
anymore, remove that bind.
// ... lines 1 - 10 | |
services: | |
// ... line 12 | |
_defaults: | |
// ... lines 14 - 20 | |
bind: | |
$markdownLogger: '@monolog.logger.markdown' | |
$isDebug: '%kernel.debug%' | |
$publicUploadsFilesystem: '@oneup_flysystem.public_uploads_filesystem_filesystem' | |
$uploadedAssetsBaseUrl: '%uploads_base_url%' | |
// ... lines 26 - 60 |
That will break UploaderHelper
because we're using that bind on top. But... we don't need it anymore! Remove the $privateFilesystem
property and the $privateUploadFilesystem
argument.
// ... lines 1 - 13 | |
class UploaderHelper | |
{ | |
// ... lines 16 - 18 | |
private $filesystem; | |
private $requestStackContext; | |
// ... lines 23 - 27 | |
public function __construct(FilesystemInterface $publicUploadsFilesystem, RequestStackContext $requestStackContext, LoggerInterface $logger, string $uploadedAssetsBaseUrl) | |
{ | |
$this->filesystem = $publicUploadsFilesystem; | |
$this->requestStackContext = $requestStackContext; | |
$this->logger = $logger; | |
$this->publicAssetBaseUrl = $uploadedAssetsBaseUrl; | |
} | |
// ... lines 35 - 127 | |
} |
But, we're still using that property in two places... the first is down in readStream
. Now that everything is stored in one filesystem, delete that old code, remove the unused argument and always use $this->filesystem
. Reading a stream is the same for public and private files.
// ... lines 1 - 13 | |
class UploaderHelper | |
{ | |
// ... lines 16 - 75 | |
public function readStream(string $path) | |
{ | |
$resource = $this->filesystem->readStream($path); | |
// ... lines 79 - 84 | |
} | |
// ... lines 86 - 123 | |
} |
Repeat that in deleteFile()
: delete the extra logic & argument, and use $this->filesystem
always.
// ... lines 1 - 13 | |
class UploaderHelper | |
{ | |
// ... lines 16 - 86 | |
public function deleteFile(string $path) | |
{ | |
$result = $this->filesystem->delete($path); | |
// ... lines 90 - 93 | |
} | |
// ... lines 95 - 123 | |
} |
Let's see... these two methods are called from ArticleReferenceAdminController
. Take off that second argument for readStream()
.
// ... lines 1 - 20 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 23 - 126 | |
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper) | |
{ | |
// ... lines 129 - 131 | |
$response = new StreamedResponse(function() use ($reference, $uploaderHelper) { | |
$outputStream = fopen('php://output', 'wb'); | |
$fileStream = $uploaderHelper->readStream($reference->getFilePath()); | |
// ... lines 135 - 136 | |
}); | |
// ... lines 138 - 145 | |
} | |
// ... lines 147 - 198 | |
} |
Then, search for "delete", and remove the second argument from deleteFile()
as well.
// ... lines 1 - 20 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
// ... lines 23 - 150 | |
public function deleteArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager) | |
{ | |
// ... lines 153 - 158 | |
$uploaderHelper->deleteFile($reference->getFilePath()); | |
// ... lines 160 - 161 | |
} | |
// ... lines 163 - 198 | |
} |
That felt great! There's one more piece of cleanup we can do, it's optional, but nice. Using the word "public" in the adapter and filesystem isn't accurate anymore! Let's use uploads_adapter
and uploads_filesystem
.
// ... line 1 | |
oneup_flysystem: | |
adapters: | |
uploads_adapter: | |
// ... lines 5 - 8 | |
filesystems: | |
uploads_filesystem: | |
adapter: uploads_adapter |
We reference this in a few spots. In liip_imagine.yaml
, take out the public_
in these two spots.
liip_imagine: | |
// ... lines 2 - 5 | |
loaders: | |
flysystem_loader: | |
flysystem: | |
filesystem_service: oneup_flysystem.uploads_filesystem_filesystem | |
// ... lines 10 - 13 | |
resolvers: | |
flysystem_resolver: | |
flysystem: | |
filesystem_service: oneup_flysystem.uploads_filesystem_filesystem | |
// ... lines 18 - 67 |
And in services.yaml
, update the "bind" in the same way. Hmm, and I think I'll change the argument name it's binding to: just $uploadFilesystem
.
// ... lines 1 - 10 | |
services: | |
// ... line 12 | |
_defaults: | |
// ... lines 14 - 20 | |
bind: | |
// ... lines 22 - 23 | |
$uploadsFilesystem: '@oneup_flysystem.uploads_filesystem_filesystem' | |
// ... lines 25 - 60 |
That will break UploaderHelper
: we need to rename the argument there. But, let's just see what happens if we... "forget" to do that. Refresh the page:
Unused binding
$uploadFilesystem
inS3Client
.
This is that generic... and somewhat "inaccurate" error that says that we've configured a bind that's never used! The error is even better if we temporarily delete the bind entirely. Ah, here it is:
Cannot autowire
UploaderHelper
: argument$publicUploadFilesystem
references an interface, but that interface cannot be autowired.
This is saying: Hey! I don't know what you want me to send for this argument! Put the bind back, then, in UploaderHelper
... here it is. Change the argument to match the bind: $uploadFilesystem
.
// ... lines 1 - 13 | |
class UploaderHelper | |
{ | |
// ... lines 16 - 27 | |
public function __construct(FilesystemInterface $uploadsFilesystem, RequestStackContext $requestStackContext, LoggerInterface $logger, string $uploadedAssetsBaseUrl) | |
{ | |
$this->filesystem = $uploadsFilesystem; | |
// ... lines 31 - 33 | |
} | |
// ... lines 35 - 123 | |
} |
Oh, and there's one more thing we can get rid of! Do we need the public/uploads
directory anymore? No! Delete it! And inside .gitignore
, we can remove the custom public/uploads/
line we added.
So by putting things in S3... it simplifies things!
Next: now that I've been complimenting our S3 setup and saying how awesome it, I have a... confession to make! We've just introduced a hidden performance bug. Let's crush it!
So I seem to be having some issues with this tutorial. I am adapting your tutorial, and instead of uploading public article images, I am attempting to upload public user profile pictures. I have been following the tutorial so far and everything is going great locally. I upload an image using Flysystem, and it works like a charm. Imagine bundle then grabs it, does its thing and puts them in the specified thumbnails folders.
Then, when I send my code up to my EC2 instance, it appears to be working but no.. Flysystem takes the file and uploads it to S3, no problem. Imagine Bundle though does not upload any of the thumbnails, but DOES try to read them as if they are there, so my site has a bunch of broken links. I have gone in to the AWS Policy checker to make sure the Id and Secret I use have access to the bucket and it all checks out. I have removed all the security options on my testing S3 bucket to make sure any of those are stopping the thumbnail uploads, and it still doesn't put the files in.
I'm running out of Ideas! Any thing you can recommend?
Here are some configs for you for context.
My latest attempt, I was creating a separate filesystem with hardcoded public visibility. Normally I just use the uploads_filesystem
oneup_flysystem.yaml
Imagine Bundle config, again set up with my most recent attempt to force a seperate public filesystem specifically for imagine.
My uploads manager is literally straight out of your tutorial with no changes other then I'm focusing on user photos and not article images and references.
Props to Kiuega below for the Visibility tip.