Skip to content

Direct uploads

A browser can upload a file straight to the bucket instead of through the application. The frontend posts to the bridge’s upload endpoint, which replies with a signed URL for a PUT to tmp/{uuid} on the upload disk; the browser then PUTs the file there. The route is registered on and off the platform, so uploads work the same way locally.

The defaults ship with the bridge. To change them, publish config/ske.php:

Terminal window
php artisan vendor:publish --tag=ske-config
'uploads' => [
'enabled' => true, // false registers no route
'url' => env('SKE_UPLOADS_URL', '/ske/signed-storage-url'), // the path the frontend posts to
'middleware' => ['web'], // the route's middleware
'gate' => 'uploadFiles', // the ability checked before signing
'expires_after' => 5, // minutes the signed URL stays valid
'disk' => 's3', // the disk that signs; its bucket receives the files
],

The path can also be changed without publishing the config: set the SKE_UPLOADS_URL variable, e.g. as a secret, to the path the frontend already posts to. A published config/ske.php takes precedence, as usual.

The upload disk must be able to sign upload URLs, which in practice means an s3 disk. The application brings the AWS SDK through league/flysystem-aws-s3-v3. The bridge doesn’t require it.

POST {url} accepts these optional fields:

Field Meaning
content_type The file’s type, signed into the upload. Defaults to application/octet-stream.
cache_control Stored as the object’s Cache-Control.
expires Stored as the object’s Expires.
bucket Only the upload disk’s bucket is accepted.
visibility Only private is accepted. No ACL is ever signed.

Any other value for bucket or visibility is refused with a 422. A successful request answers 201:

{"uuid": "…", "bucket": "…", "key": "tmp/…", "url": "https://…", "headers": {"Content-Type": "image/png"}}

The browser PUTs the file to url with headers, leaving out Host. The application then moves the file from key to its permanent place, e.g. Storage::copy($key, 'avatars/'.$uuid). Files left under tmp/ should be expired by a bucket lifecycle rule.

Every request is authorized with Gate::authorize($gate, [$user, $bucket]). If the application defines nothing for the ability, every request is refused with a 403. Define it as a gate:

Gate::define('uploadFiles', function (User $user, User $uploader, string $bucket) {
return true;
});

Or define it as an uploadFiles method on App\Policies\UserPolicy:

public function uploadFiles(User $user): bool
{
return true;
}

Guests reach the gate with a null user, so a gate that allows them must accept a nullable user.

The browser PUTs the file to the bucket’s own domain, so the bucket needs a CORS rule that allows PUT from the application’s origin, with the Content-Type and Cache-Control headers. Without it, the browser blocks the upload.