Streaming videos in Safari with http2

Streaming an authenticated video to Safari video element with http2 enabled.

Image for post
Image for post
Photo by Sam McGhee on Unsplash

TL;DR

  1. A friend called me, to help the marketing agency he works at with an adult website where they couldn’t figure out why videos weren’t working on Safari.
  2. Safari sends a `Range` header and expects the content streamed to be served chunked when HTTP2 is enabled
  3. Files were hosted on AWS S3 and the php class that was implemented to serve S3 files as file streams didn’t implement `Content Range`
  4. Lowering server load and increasing speed by serving files from DO instead of S3
  5. Authentication; When Safari requests the video URL, it doesn’t send cookies with it. But in this case you need to be authenticated to be able to see the videos.

The problem

A very close friend of mine who works in a marketing agency called me because he had a problem with a project.

“Do you know anything about streaming videos?”

I’m not an expert on the matter but I said, let’s have a look at it nonetheless and maybe I could be of help.

The website was working fine everywhere, videos were working except on Safari mobile. Testing the website on Safari desktop it didn’t work there either which was a good thing because I could better debug the problem.

I asked to be given access to the source code of the project and to be told how they have set it up.

For the project they had chosen these technologies

  • Backend: Laravel
  • Frontend: Blade templating
  • Git: Bitbucket
  • Deployment: Laravel Forge
  • SSL: Let’s Encrypt
  • Web Server: Nginx
  • Server: DigitalOcean
  • Storage: AWS S3

Everything is pretty straight-forward and there’s nothing too complicated and the way the whole system worked is as follows:

  1. Considering the user has already signed up and is logged in.
  2. User open a video page
  3. The backend identifies the video and streams it using AWS S3 Stream Wrapper
$client = new Aws\S3\S3Client([/** options **/]);// Register the stream wrapper from an S3Client object
$client->registerStreamWrapper();

The process didn’t have bugs apparently because it did work on all the other browsers. Although I wasn’t convinced of serving files from S3 passing them through the server it should have worked.

Now that I knew how it all worked, I had a look at the frontend side. Opened the page on two different browsers, Safari and Chrome.

In fact it wasn’t working on Safari. There was an error message on the console but it didn’t say much. (Can’t find it out)

I asked if they had tried different file formats because webm isn’t supported on Safari and it seemed like they were using webm at first. They had tried mp4 as well with the same error.

After searching online for the error I had no luck. So I copied the HTTP call Safari was making as cURL and tried it directly on the terminal.

Image for post
Image for post
Terminal view of the cURL call

At this point it was clear that there was something wrong with the http2 protocol but what was it?

Comparing it with the same call Chrome was making I figured out that Safari was sending the range header which Chrome wasn’t. In fact removing it, from the Safari cURL worked perfectly.

It is interesting to note the differences between the two browsers on how they request an HTML5 video

Safari

curl 'https:/adult-website-hidden.tld/trailer/trailer.m4v' \
-XGET \
-H 'Accept: */*' \
-H 'Range: bytes=0-37947554' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15' \
-H 'Referer: https:/adult-website-hidden.tld/video/full-video' \
-H 'Accept-Encoding: identity' \
-H 'Connection: Keep-Alive' \
-H 'Origin: https:/adult-website-hidden.tld' \
-H 'X-Playback-Session-Id: C95102C1-299E-432C-B2AD-7C0DA935FB0C'

Chrome

curl 'https://adult-website-hidden.tld/trailer/trailer.m4v' \
-H 'authority: adult-website-hidden.tld' \
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36' \
-H 'accept: */*' \
-H 'sec-fetch-site: same-origin' \
-H 'sec-fetch-mode: cors' \
-H 'sec-fetch-dest: video' \
-H 'referer: https://adult-website-hidden.tld/video/full-video' \
-H 'accept-language: en-US,en;q=0.9,it;q=0.8' \
-H 'cookie: _iub_cs-39475231=%7B%22timestamp%22%3A%222020-04-26T10%3A08%3A01.240Z%22%2C%22version%22%3A%221.20.4%22%2C%22consent%22%3Atrue%2C%22id%22%3A39475231%7D; XSRF-TOKEN=eyJpdiI6ImNiVThxWWVjUWY3dkZFZGowaTJkMlE9PSIsInZhbHVlIjoiUXBvUm1tRmRoU2c3d1VoYmZsazV1MFF0RFwvNTZpcmF0S3FkWk93SlBkeXJyb003cU52dWtVY2dOUlJDRmdJMXoiLCJtYWMiOiIxNDQ2NTNkY2QxMTQ3YjY0ZjVhNjJlNTk2MWQ4M2IxMWQ1NTlkZTNmODBiYTUxNDA1Y2M0ZmUzYWYyZGM0M2RjIn0%3D; adult-website-session=eyJpdiI6IklUN3NcLzhya3RuSEQ3bHJuQnBQbFJBPT0iLCJ2YWx1ZSI6IkowMlBWTGZEMmM3UngwcDVNSVlNazk2Y0J5UTd3STNiVk9RNzZHbGdNR1VTUGw2VkJkUkJ3cFdlUGFBdHV0RVQiLCJtYWMiOiI3NDYwYjU1ZmZhMjc3NDA4MmYxOWQwOWNlMGZlM2IwNWJlZDZlYTRmNDUxMWYwYzIwMTE4MGZmZjJlMDI1ODE2In0%3D' \
-H 'range: bytes=5636096-' \
--compressed

The range header is an http2 supported header but it isn’t usually implemented on the web server side (Nginx), it is implemented on the application side which knows the length of the file it is serving.

So as a quick fix I tried disabling http2 and see if it would work.

This was tricky on its own, as disabling it on the server block alone wouldn’t work. Nginx enables http2 for all the ssl enabled server blocks as long as one server block has http2 enabled.

I had to disable it on all the server blocks and videos started working on Safari. It wasn’t sending the range header anymore.

I wasn’t really satisfied with the above solution, although it did the work, for these reasons

  1. Streaming through http1 meant that the browser would always download the full file generating a lot of traffic and making the website slow although it worked
  2. http2 should have been working, so it was interesting to dig deep and understand why the application not handling the content-range.

As I said above, we were using the AWS SDK to create a Stream Wrapper and load the media file on the server, then serve it like this through

$stream = new S3FileStream($file, 's3');return $stream->output();

With S3FileStream being a custom response class that you can find here

FileStream php class, to stream a file from S3

It was exactly this class that didn’t support content-range headers. So I found a better one online and used that instead.

New class to stream videos

So the new implementation in the Laravel controller to stream the video became like this

$stream = new VideoStream($stream_file);return response()->stream(function () use ($stream) {
$stream->start();
});

As mentioned by the author of the code, it needed some adapting for S3 files.

After testing this solution, I enabled again http2 and it worked.

There are different advantages http2 brings, such as

  1. The browser can load the video a chunk at a time
  2. It can be preloaded up to a certain point (first 20s)
  3. When the user want’s to watch the video from a certain point, it will be loaded from that point onwards.
  4. Due to the above reasons, the server also has less load and can handle more requests

Yet again I wasn’t very satisfied with the speed with which the videos were being streamed.

The data flow was a little off. Videos were stored on S3, but if they would be served from S3 directly, the content could be downloaded or shared easily. So they were being served by the server on Digital Ocean.

  • So a video would go from AWS S3 to Digital Ocean and then to the user.
  • There was too much traffic to make it fit in the respective bandwidth.

A hypothetical solution would be to handle video encoding through AWS services and through a series of Lambda functions generate unique temporary links for each video and serve them through Cloudfront. This though would have exceeded the budget both for the time needed to implement something like this as well as for the costs of this kind of setup.

I tested out a simpler approach. I cut off S3 altogether (which was already costing way too much, but it made sense). The idea was that, moving videos to the server on Digital Ocean, shouldn’t add more load or traffic since it was already serving the videos and doing all the hard work. I would expect to have a decrease in traffic and load.

  • So I added a separate volume to the server which would be used only to store the videos.
  • I mount it and moved the videos there.

As expected it was faster to serve videos and the lead on the server decreased.

New Problem

After fixing the streaming problem, the authentication was enabled so the videos wouldn’t be accessible to everyone but only to logged in users.

Fair enough. With Laravel it’s pretty simple to add.

if ($user = Auth::user()) {
abort('401');
}

Safari is not working again.

The way Auth::user() works is by checking the user session which in turn is kept alive through a cookie. But for some reason the folks at Apple in charge of developing Safari decided that they wouldn’t send cookies when making a video request.

To overcome this, I added the following checks.

  1. The video URL wouldn’t be a simple https://adult-website-hidden.tld/video/full-video which was intercepted by the controller
  2. I used Laravel’s temporarySignedUrl on the route to get a URL that would be valid only if it had the token Laravel signed it with and it would be valid only for 20 minutes.
  3. The time limit would be extended for each video chunk in order to keep it valid for as long as a user is watching the video.
  4. I added an extra token to the URL that would log the user that is requesting to watch the video. A hash saved on the database which tells what video the user is watching.
  5. Every time the user watches a new video, the old token is invalidated so the link wouldn’t work thus making it unsharable.
  6. If the video slug identified by the token doesn’t match the slug itself it still wouldn’t be valid.
private function accessControlToken(Request $request, $slug, $file)
{
if (!$request->hasValidSignature()) {
abort('401');
}
// If not in Safari, Auth::user works correctly
// and so does authentication
if ($user = Auth::user()) {
return $user;
}
if (!$request->token) {
abort('404');
}
$token = VideoToken::where('token', '=', $request->token)->firstOrFail(); $user = $token->user; if ($token->expired()) {
abort('401');
}
if ($token->video->slug !== $slug) {
abort('401');
}
// Verify token here
if ($user->subscription->active != true) {
abort('401');
}

$token->extend();

return $user;
}

With the solution on HTTP2 and the like, the website seems stable and fast. The implementations to add some extra security seem to be working as well.

I hope I covered everything. Thanks for having the patience to read this far.
Stay tuned for more :)

Enjoying Stockholm and Sweden 🇸🇪 https://progress44.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store