Documentation

Storing Private Files

V2 of our Asset Platform does not support fully private files. If you need to store private files and your project is using our V2 Asset Platform you can upgrade using the guide here.
 

By default, all files uploaded to the Servd Asset Platform are publicly downloadable, however there are situations in which you might need to store fully private files which can only be downloaded via an authenticated method within Craft.

Our method for achieving this is simple, any files stored within a directory on the Asset Plaftorm named private will be made private.

We recommend creating a separate Craft Volume for your private files, and linking this with a Servd Filesystem that has the subfolder property set to private to provide a clean separation between your public and private files.

How private files can be accessed #

Private files can't be downloaded through the CDN or any other public URL. A request for a private file's public URL is refused.

The only way to fetch a private file is through the S3 API, using normal S3 authentication. The Servd plugin already holds the right credentials, so Craft can still read private files as usual. 

If you'd like end users to be able to access these files but with additional authentication in place, there are two options:

Option 1: Stream files through PHP (recommended) #

We recommend serving private files from a controller action in your own Craft module. The action checks that the current user is allowed to see the file, then streams it back through PHP. This gives you full control over who can download what. Access is checked on every request, and the storage URL is never shown to the end user.

<?php

namespace modules\controllers;

use Craft;
use craft\elements\Asset;
use craft\web\Controller;
use yii\web\ForbiddenHttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;

class PrivateFilesController extends Controller
{
   protected array|int|bool $allowAnonymous = false;

   public function actionDownload(int $assetId): Response
   {
       $asset = Asset::find()
           ->id($assetId)
           ->volume('privateFiles')
           ->one();

       if (!$asset) {
           throw new NotFoundHttpException('File not found');
       }

       // Replace with your own authorisation rules
       if (!Craft::$app->getUser()->checkPermission('accessPrivateFiles')) {
           throw new ForbiddenHttpException('You are not allowed to access this file');
       }

       return $this->response->sendStreamAsFile($asset->getStream(), $asset->getFilename(), [
           'mimeType' => $asset->getMimeType(),
           'inline' => false,
       ]);
   }
}

Optional: cache files on disk

If the same private files are downloaded often, you can cache them on local disk. Repeat requests then skip the Asset Platform. You can put the asset's modified date in the cache key so a replaced file is fetched again:

use craft\helpers\FileHelper;

$cachePath = Craft::getAlias('@storage/runtime/private-files/')
   . $asset->id . '-' . $asset->dateModified->getTimestamp();

if (!file_exists($cachePath)) {
   FileHelper::createDirectory(dirname($cachePath));

   // Write to a temp file first so concurrent requests never see a partial file
   $tmpPath = $cachePath . '.' . uniqid() . '.tmp';
   $out = fopen($tmpPath, 'wb');
   stream_copy_to_stream($asset->getStream(), $out);
   fclose($out);
   rename($tmpPath, $cachePath);
}

return $this->response->sendFile($cachePath, $asset->getFilename(), [
   'mimeType' => $asset->getMimeType(),
   'inline' => false,
]);

The local disk isn't shared between PHP instances, so each instance builds its own on-disk cache..

Option 2: S3 pre-signed URLs #

You can also create standard S3 pre-signed URLs, which let anyone holding the URL download a private file directly from the Asset Platform until the URL expires. This keeps large downloads away from PHP. The trade-offs:

- Once a URL is issued, you can't revoke it before it expires.
- Downloads come straight from storage rather than through our CDN and have a different domain/URL structure.

To create one, get the Flysystem adapter from the Servd Filesystem that your Volume uses. Then use the AWS SDK S3Client inside it to sign a GetObject request:

<?php

use craft\elements\Asset;
use League\Flysystem\Config;
use servd\AssetStorage\AssetsPlatform\Fs as ServdFs;

function getPrivateAssetSignedUrl(Asset $asset, string $expires = '+10 minutes'): string
{
    $volume = $asset->getVolume();
    $fs = $volume->getFs();

    if (!$fs instanceof ServdFs) {
        throw new \RuntimeException('Asset is not stored on the Servd Asset Platform');
    }

    // adapter() is protected on Craft's FlysystemFs, so call it from within the Fs's scope
    $adapter = (function () {
        return $this->adapter();
    })->call($fs);

    return $adapter->temporaryUrl(
        // Path relative to the Filesystem: the Volume's subpath + the asset's path
        $volume->getSubpath() . $asset->getPath(),
        new \DateTimeImmutable($expires),
        new Config([
            // Optional: force a download with the original filename
            'get_object_options' => [
                'ResponseContentDisposition' => 'attachment; filename="' . $asset->getFilename() . '"',
            ],
        ])
    );
}

Run your own authorisation checks first, as in Option 1, then redirect to the signed URL:

return $this->redirect(getPrivateAssetSignedUrl($asset));

Keep expiry times short. Anyone who has the URL can download the file until it expires.