Add caching support to file downloads
authorAlexander Ebert <ebert@woltlab.com>
Sat, 11 May 2024 11:43:35 +0000 (13:43 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Sat, 8 Jun 2024 10:19:39 +0000 (12:19 +0200)
wcfsetup/install/files/lib/action/FileDownloadAction.class.php
wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php
wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php
wcfsetup/install/files/lib/system/file/processor/FileCacheDuration.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php

index c699957c2f86b8b08326aa446a0bca8cdc1525dd..68416397807c51c74f93066d22a195b5428689f6 100644 (file)
@@ -3,6 +3,7 @@
 namespace wcf\action;
 
 use Laminas\Diactoros\Response;
+use Laminas\Diactoros\Response\EmptyResponse;
 use Laminas\Diactoros\Stream;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -49,6 +50,17 @@ final class FileDownloadAction implements RequestHandlerInterface
             throw new PermissionDeniedException();
         }
 
+        $eTag = \sprintf(
+            '"%d-%s"',
+            $file->fileID,
+            \substr($file->fileHash, 0, 8),
+        );
+
+        $httpIfNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
+        if ($httpIfNoneMatch === $eTag) {
+            return new EmptyResponse(304);
+        }
+
         $processor->trackDownload($file);
 
         $filename = $file->getPathname();
@@ -68,10 +80,39 @@ final class FileDownloadAction implements RequestHandlerInterface
             default => ContentDisposition::Attachment,
         };
 
-        return $response->withHeader('content-type', $mimeType)
+        // Prevent <script> execution in the context of the community's domain if
+        // an attacker somehow bypasses 'content-disposition: attachment' for non-inline
+        // MIME-Types. One possibility might be a package extending $inlineMimeTypes
+        // in an unsafe fashion.
+        //
+        // Allow style-src 'unsafe-inline', because otherwise the integrated PDF viewer
+        // of Safari will fail to apply its own trusted stylesheet.
+        $response = $response
+            ->withHeader('content-security-policy', "default-src 'none'; style-src 'unsafe-inline';")
+            ->withHeader('x-content-type-options', 'nosniff');
+
+        $lifetimeInSeconds = $processor->getFileCacheDuration($file)->lifetimeInSeconds;
+        if ($lifetimeInSeconds !== null) {
+            $expiresAt = \sprintf(
+                '%s GMT',
+                \gmdate('D, d M Y H:i:s', $lifetimeInSeconds)
+            );
+            $maxAge = \sprintf(
+                'max-age=%d, private',
+                $lifetimeInSeconds ?: 0,
+            );
+
+            $response = $response
+                ->withHeader('Expires', $expiresAt)
+                ->withHeader('Cache-control', $maxAge);
+        }
+
+        return $response
+            ->withHeader('content-type', $mimeType)
             ->withHeader(
                 'content-disposition',
                 $contentDisposition->forFilename($file->filename),
-            );
+            )
+            ->withHeader('ETag', $eTag);
     }
 }
index 6511fed16d54fa900dae0c5519c40951d4af0521..71e6109457c536c61b695ee5a476ef02bacdb699 100644 (file)
@@ -29,6 +29,12 @@ abstract class AbstractFileProcessor implements IFileProcessor
         return ['*'];
     }
 
+    #[\Override]
+    public function getFileCacheDuration(File $file): FileCacheDuration
+    {
+        return FileCacheDuration::oneYear();
+    }
+
     #[\Override]
     public function getResizeConfiguration(): ResizeConfiguration
     {
index a849b49115ce647a0f54f9f9f16f883226a883ee..6eecb66a7db00b86a7caf33fe586c2ed509f4ef0 100644 (file)
@@ -201,12 +201,35 @@ final class AttachmentFileProcessor extends AbstractFileProcessor
             return;
         }
 
+        // Side effect: Renew the lifetime of a temporary attachment in case
+        //              the user is still writing their message, preventing it
+        //              from vanishing prematurely.
+        if ($attachment->tmpHash) {
+            (new AttachmentEditor($attachment))->update([
+                'uploadTime' => \TIME_NOW,
+            ]);
+
+            // Do not update the download counter for temporary attachments.
+            return;
+        }
+
         (new AttachmentEditor($attachment))->update([
             'downloads' => $attachment->downloads,
             'lastDownloadTime' => \TIME_NOW,
         ]);
     }
 
+    #[\Override]
+    public function getFileCacheDuration(File $file): FileCacheDuration
+    {
+        $attachment = Attachment::findByFileID($file->fileID);
+        if ($attachment?->tmpHash === '') {
+            return FileCacheDuration::oneYear();
+        }
+
+        return FileCacheDuration::shortLived();
+    }
+
     private function getAttachmentHandlerFromContext(array $context): ?AttachmentHandler
     {
         try {
diff --git a/wcfsetup/install/files/lib/system/file/processor/FileCacheDuration.class.php b/wcfsetup/install/files/lib/system/file/processor/FileCacheDuration.class.php
new file mode 100644 (file)
index 0000000..fcbfdbb
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+
+namespace wcf\system\file\processor;
+
+/**
+ * Specifies the maximum cache lifetime of a file in the browser.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ */
+final class FileCacheDuration
+{
+    public static function shortLived(): self
+    {
+        return new self(5 * 60);
+    }
+
+    public static function oneYear(): self
+    {
+        return new self(365 * 86_400);
+    }
+
+    public static function doNotCache(): self
+    {
+        return new self(null);
+    }
+
+    public static function customDuration(int $seconds): self
+    {
+        if ($seconds < 1) {
+            throw new \OutOfBoundsException('The custom duration must be a positive integer greater than zero.');
+        }
+
+        return new self($seconds);
+    }
+
+    public function allowCaching(): bool
+    {
+        return $this->lifetimeInSeconds !== null;
+    }
+
+    private function __construct(
+        public readonly ?int $lifetimeInSeconds,
+    ) {
+    }
+}
index 0fdb5ae120688458b950e883ef6621b03c7ef2ab..bf8f3218d24506257196b8ccf898fcb82e4df52a 100644 (file)
@@ -75,6 +75,12 @@ interface IFileProcessor
      */
     public function getAllowedFileExtensions(array $context): array;
 
+    /**
+     * Limits how long a file may be cached by the browser. Should use a low
+     * value for files that are not persisted yet.
+     */
+    public function getFileCacheDuration(File $file): FileCacheDuration;
+
     /**
      * Controls the client-side resizing of some types of images before they are
      * being uploaded to the server.