Protected Files with download attribute

Dear forum

I followed this cookbook to protect specific files:

In the panel within a text field nested in a layout field I point to one of these protected files with selecting the desired text, adding an anchor, selecting the file, save

My Text:

  • Download as Zip (pointing to the protected file)
  • Download as MP4 (pointing to the protected file)

This gets rendered as such:

<a href="https://my-domain.com/protected-downloads/download_as_zip.zip" >Download as Zip</a>
<a href="https://my-domain.com/protected-downloads/download_as_mp4.mp4" >Download as MP4</a>

Everything works fine, the file URL is not accessible when not logged in, the downloads are working.

However, on Android devices this does not work and the download is corrupt because the download is an HTML instead of ZIP or MP4.

Now, I try to fix this with adding the “download” attribute to the anchor tag.
According to the docs, the kirby tag “file” gets rendered with the download attribute.

But using kirbytag within the text field does not work with the “file” tag.

(file: download_as_zip.zip)
=> results in <p>download_as_zip.zip</p>

Sidenote: The link tag works (link: dashboard text: Dashboard)

How would you access the protected files and add the download attribute?

Thanks a lot

I solved this now with using the markdown block as an additional fieldset in the layouts.

I thought I can avoid this as it is not so intuitive for content managers.

Anyway, the downloads on android still do not work even with the download attribute.
But that is obviously not a kirby issue?

I may need to set proper headers somewhere for the protected downloads.
And have to investigate further

I just discovered on iOS it’s the same.

Linking to a protected file results in a download of an html file.

Linking to a file which is not uploaded as a protected file works fine.

I fixed it now with setting the headers on the route.

            header('Content-Description: File Transfer');
            header('Content-Type: ' . $file->mime());
            header('Content-Disposition: attachment; filename="' . $file->filename() . '"');
            header('Content-Transfer-Encoding: binary');
            header('Expires: 0');
            header('Cache-Control: must-revalidate');
            header('Pragma: public');
            header('Content-Length: ' . $file->size());

            // Stream the file
            readfile($file->root());

As maybe others can I also run into this, a small hint or comment would be nice?

Something like

  'routes'       => [
    [
      'pattern' => 'downloads/(:any)',
      'action'  => function ($filename) {
        if (kirby()->user() &&
          ($page = page('downloads')) &&
          $file = $page->files()->findBy('filename', $filename)) {
          // Set headers manually here
          return $file->download();
        }
        return site()->errorPage();
      },
    ],
  ],

Must admit that I cannot completely follow you.

$file->download()

in the example route is supposed to set all relevant headers, so no need to set them manually as your comment suggests.

/*
* Automatically sends all needed headers
* for the file to be downloaded and
* echos the file’s content
*
* @param string|null $filename Optional filename for the download
*/
public function download(string|null $filename = null): string
{
return Response::download($this->root(), $filename ?? $this->filename());
}

This here is the Response::download() method:

	public static function download(
		string $file,
		string|null $filename = null,
		array $props = []
	): static {
		if (file_exists($file) === false) {
			throw new Exception(message: 'The file could not be found');
		}

		$filename ??= basename($file);
		$modified   = filemtime($file);
		$body       = file_get_contents($file);
		$size       = strlen($body);

		$props = array_replace_recursive([
			'body'    => $body,
			'type'    => F::mime($file),
			'headers' => [
				'Pragma'                    => 'public',
				'Cache-Control'             => 'no-cache, no-store, must-revalidate',
				'Last-Modified'             => gmdate('D, d M Y H:i:s', $modified) . ' GMT',
				'Content-Disposition'       => 'attachment; filename="' . $filename . '"',
				'Content-Transfer-Encoding' => 'binary',
				'Content-Length'            => $size,
				'Connection'                => 'close'
			]
		], $props);

		return new static($props);
	}

Thanks for the reply.
I try to summarize again.

Here is my code for the protected download files without the manually set headers:

 'routes'       => [
    [
      'pattern' => '(:all)/protected-downloads/(:any)',
      'action'  => function ($parent, $filename) {
        if (
          kirby()->user() &&
          ($page = page($parent)) &&
          $file = $page->files()->findBy('filename', $filename)
        ) {
          return $file->download();
          } 
        return site()->errorPage();
      },
    ],
  ],
  'components' => [
    'file::url' => function ($kirby, $file) {
      if ($file->template() === 'protected') {
        return $kirby->url() . '/' . $file->parent()->id() . '/protected-downloads/' . $file->filename();
      }
      return $file->mediaUrl();
    },
    'file::version' => function ($kirby, $file, array $options = []) {

      static $original;

      // if the file is protected, return the original file
      if ($file->template() === 'protected') {
        return $file;
      }
      // if static $original is null, get the original component
      if ($original === null) {
        $original = $kirby->nativeComponent('file::version');
      }

      // and return it with the given options
      return $original($kirby, $file, $options);
    },
  ],

My Bueprint:

files:
        label: Protected Downloads
        type: files
        template: protected

In a layout, I add a text block, select a text, add an anchor, select one of the protected files I uploaded before to this page.

The rendered HTML on this page:
<a href="https://my-domain.com/page/sub-page/protected-downloads/my_download.zip" title="Zip Download"><strong>Download ZIP</strong></a>

=> A click on this link results in an HTML download (see Screenshot)

This is not a problem for users with desktop browsers as the download is in the correct format in the end (here a Zip file).

But on Android and iOS the download remains an HTML file and is not usable after the download (cryptic HTML file).

I am not sure what the difference is to the file->download() method.

But I can confirm that when setting the headers manually in the route, everything works now (=> with the code posted above).

Before with field->download()

After with extra Headers

Wondering if there are some headers missing from `$file->download() and if so, which ones, or if this is a general issue of headers not being sent.