Fix: Correct filename when saving files served via @@display-file

We use @@display-file rather than @@download because we want files (particularly PDFs) to open directly in the browser. We don't want users to have to download a file just to view it, and we don't want to be at the mercy of individual browser settings that might force a download when using @@download.

The issue is that when a user views a PDF in the browser via @@display-file and then saves it (e.g. from the browser's PDF viewer), it saves as document.pdf instead of the actual filename.

This happens because the DisplayFile view in plone.namedfile intentionally omits the Content-Disposition header entirely — so the browser has no filename hint when saving.

For comparison, @@download sets Content-Disposition: attachment; filename*=UTF-8''actual-name.pdf, which gives the browser the correct filename but forces a download.

The fix

We override @@display-file to add Content-Disposition: inline; filename*=UTF-8''actual-name.pdf. The key is the inline directive — it preserves the browser's default display behaviour (opening PDFs in the viewer, displaying images, etc.), while the filename* parameter provides the correct name when saving.

The result:

  • Before: No Content-Disposition header → browser saves as document.pdf
  • After: Content-Disposition: inline; filename*=UTF-8''actual-name.pdf → browser saves with the correct name

Implementation

Two files in your backend add-on's browser/ directory.

browser/display_file.py

from plone.namedfile.browser import DisplayFile as BaseDisplayFile
from plone.namedfile.utils import set_headers
from urllib.parse import quote


class DisplayFile(BaseDisplayFile):
    """Override @@display-file to include the filename in the
    Content-Disposition header while keeping inline disposition.
    """

    def set_headers(self, file):
        if hasattr(file, "contentType"):
            from plone.namedfile.utils import extract_media_type

            mimetype = extract_media_type(file.contentType)
            if self.use_denylist:
                if mimetype in self.disallowed_inline_mimetypes:
                    return super().set_headers(file)
            else:
                if mimetype not in self.allowed_inline_mimetypes:
                    return super().set_headers(file)

        canonical = self.get_canonical(file)
        set_headers(file, self.request.response, canonical=canonical)

        filename = getattr(file, "filename", None)
        if filename:
            if not isinstance(filename, str):
                filename = str(filename, "utf-8", errors="ignore")
            quoted = quote(filename.encode("utf-8"))
            self.request.response.setHeader(
                "Content-Disposition",
                f"inline; filename*=UTF-8''{quoted}",
            )

ZCML registration in browser/configure.zcml

<browser:page
    name="display-file"
    for="*"
    class=".display_file.DisplayFile"
    permission="zope2.View"
    layer="your.addon.interfaces.IYourAddonLayer"
    />

Replace your.addon.interfaces.IYourAddonLayer with your add-on's actual browser layer interface.

How it works

The override keeps the parent class's mimetype security logic intact — mimetypes not on the allowlist (SVG, HTML, etc.) still get forced to Content-Disposition: attachment via the Download view, preserving XSS protection.

For allowed inline mimetypes, it:

  1. Calls the standard set_headers utility without a filename, which sets Content-Type, Content-Length, and Accept-Ranges as normal.
  2. Adds Content-Disposition: inline; filename*=UTF-8''<name> — using the RFC 5987 encoding that plone.namedfile already uses elsewhere for filename headers.

Tested with plone.namedfile 7.3.0 on Plone 6.

3 Likes

@aboycalledhero Your change makes sense to me. Would you be willing to submit a pull request to plone.namedfile? Ping me if you need any help with the process.

1 Like

Seems the exact example here:

Also, security considerations seems ok with

Nice!

1 Like

Absolutely. Will do.

1 Like