Files
summercms/docs/services/storage.md
Jakub Zych 2e22e9f9a6 fix(19.3-03): name stored uploads after their sniffed content through attach.DiskExtension
- DiskExtension: a fixed table of specific sniffed types keeps a matching client
  extension or uses the canonical one; generic sniffs keep the client extension
  unless it is script-capable (then txt or bin); never asks package mime
- Store names the blob with DiskExtension(sniffed, ext); file_name, the allow-list
  and the stored content_type are unchanged
- TestStoreDiskExtensionSmoke: a PNG uploaded as x.html is stored and served as .png,
  report.docx keeps .docx, an HTML body named note.txt keeps .txt
- attachments docs on disk names, storage deploy note on nosniff and allow-lists,
  lagoon README
2026-10-15 18:09:49 +02:00

8.5 KiB

title, description, section, order
title description section order
Storage Configure the uploads bucket that serve opens, choose file, memory or S3 bucket URLs, serve stored files, and size upload routes. services 90

Storage

WinterCMS stores uploads on a Laravel filesystem disk. SummerCMS stores them in one gocloud.dev bucket, opened from storage.uploads.bucket_url by the attach package of lagoon. The serve command opens the bucket at start-up, before it accepts requests, and publishes it on the application; an empty bucket_url stops the start-up.

Bucket URLs

URL Stores
file:///var/lib/acme/uploads In a directory on the server.
mem:// In memory, for tests. Everything is lost when the process exits.
s3://acme-uploads?region=eu-central-1 In an S3 or S3-compatible bucket. The binary must be built with the summercms_s3 tag; see S3 and S3-compatible buckets.

The keys go in config/storage.yaml:

uploads:
  bucket_url: file:///var/lib/acme/uploads
  public_path_prefix: /storage/uploads

Files are laid out as WinterCMS lays out its uploads disk, so a copy of a WinterCMS storage/app/uploads/public directory can serve as the bucket after a port. public_path_prefix is the URL prefix that file and thumbnail URLs start with. attach.PublicURL builds the URL of any blob key from it, and attach.File.URL the URL of an original.

The WinterCMS layout

A port whose clients already store or compare upload URLs keeps WinterCMS's URLs too. WinterCMS serves public uploads from storage/app/uploads/public under the URL path /storage/app/uploads/public (cms.storage.uploads.path plus /public). Root the bucket at that directory and use the same prefix:

uploads:
  bucket_url: file://./storage/app/uploads/public
  public_path_prefix: /storage/app/uploads/public

An original then has the URL /storage/app/uploads/public/<partition>/<disk_name>, as WinterCMS's File::getPath() returns, and a 200 by 200 cropped thumbnail /storage/app/uploads/public/<partition>/thumb_<id>_200_200_0_0_crop.<ext>, as getThumb() returns. The framework default stays /storage/uploads, so only an application that sets these keys changes its URLs.

Application code gets the bucket with app.Lookup[*blob.Bucket]() and reads and writes it through the gocloud.dev/blob API. Model attachments, thumbnails and deleting files after commit are covered in Attachments.

S3 and S3-compatible buckets

The S3 driver (gocloud.dev/blob/s3blob) is not linked into every binary, since it brings in the AWS SDK. A binary links it when it is built with the Go build tag summercms_s3 (attach.S3BuildTag). The application never imports the driver itself: list the tag under build.tags in summer.yaml and summer build passes it to go build:

module: git.golem15.com/acme/acme
binary: acme
plugins:
  - id: acme.blog
    module: git.golem15.com/acme/acme/plugins/blog
build:
  tags:
    - summercms_s3

summer build prints build tags: summercms_s3 before it builds, and summer dev builds the same way. A build script that runs its own go build after summer build, for example to cross-compile a release, must repeat the tag: go build -tags summercms_s3 -o bin/acme ., or GOFLAGS=-tags=summercms_s3 in its environment. A binary built without the tag refuses an s3:// bucket URL at start-up:

attach: storage.uploads.bucket_url uses s3:// but this binary was built without the summercms_s3 build tag; list summercms_s3 under build.tags in summer.yaml and rebuild with summer build (a raw go build needs -tags summercms_s3, or GOFLAGS=-tags=summercms_s3)

The bucket is configured only through bucket_url and its query parameters, as gocloud.dev defines them; there are no separate s3.* keys. For AWS:

uploads:
  bucket_url: s3://acme-uploads?region=eu-central-1

For an S3-compatible service (MinIO, Cloudflare R2, Hetzner Object Storage and others), add the service's endpoint, path-style addressing and, for services that do not support the newer AWS checksums, the two checksum parameters:

uploads:
  bucket_url: s3://acme-uploads?region=auto&endpoint=https://storage.example.com&use_path_style=true&request_checksum_calculation=when_required&response_checksum_validation=when_required

Credentials come from the standard AWS environment: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, a profile (AWS_PROFILE, or profile= in the URL) or an instance role. Never put keys in bucket_url or in a config file. The bucket opens at start-up without a request to the service, so wrong credentials or a missing bucket show up on the first upload or read.

The Serving files route reads through the bucket, so it serves an S3 bucket exactly as it serves a directory.

Serving files

The framework mounts no file route unless you ask for one. Set storage.uploads.serve to true (environment SUMMER_STORAGE__UPLOADS__SERVE=true, default false) and the router registers GET <public_path_prefix>/{path...} on attach.StaticHandlerPublic:

storage:
  uploads:
    bucket_url: file://./storage/app/uploads/public
    public_path_prefix: /storage/app/uploads/public
    serve: true

The route serves originals and thumbnails and answers 404 for a missing file, an invalid path and a file whose system_files row is not public (is_public false). The stored content type is the one sniffed from the file's bytes at upload, so an uploaded text file that starts with <html> is stored as text/html; X-Content-Type-Options: nosniff alone does not stop a browser from rendering a file whose declared type is already HTML. The route therefore protects the application origin in three ways:

  • Every response carries X-Content-Type-Options: nosniff.
  • Every response carries Content-Security-Policy: default-src 'none'; img-src 'self'; media-src 'self'; sandbox. A file opened as a page runs no script and gets an opaque origin, so it cannot read the application's cookies or storage. The policy does not affect an <img> or <video> that embeds the file.
  • A file whose type is not a raster image (image/jpeg, image/png, image/gif, image/webp or image/avif) is sent with Content-Disposition: attachment, so the browser downloads it instead of showing it. That includes HTML, SVG, PDF and plain text.

With serve on, public_path_prefix must be an absolute URL path such as /storage/uploads: a leading slash, no scheme or host, no query, no empty, . or .. segment and no { or }. Start-up fails with an error naming the key otherwise, for example for the WinterCMS disk path storage/app/uploads/public or a CDN URL. With serve off the key only builds file URLs, so a CDN origin is fine there.

The route looks up the bucket and the database on each request: route:list lists it without opening either, and it answers 404 until serve has published them. The route owns the whole prefix, so start-up fails when a plugin registers a route at or under it.

Without the key, the application decides where files are served: mount attach.StaticHandlerPublic itself, or put a web server or CDN in front of the bucket directory. Either way, serve uploads from a separate origin when you can; the Attachments page explains why. The built-in route is meant for development and for deployments that cannot split the origin yet.

Deploy note. When a static server such as nginx, or a CDN, serves the uploads directory or bucket directly, it picks the Content-Type from the file extension and sends none of the route's headers. attach.Store names each file after its sniffed content (attach.DiskExtension), so a PNG uploaded as x.html is stored as .png, but configure the server as the route behaves anyway: send X-Content-Type-Options: nosniff on every upload, and serve only the extensions the application accepts (for example the image and document types in attach.DefaultFileExtensions), answering 404 for anything else.

Upload size

Every non-raw route has a request body limit of http.body_limits.default_bytes. A route that accepts uploads raises its own limit with the body.limit:<bytes> middleware; see Routing. http.body_limits.upload_bytes is required and validated at start-up, but the framework applies it to no route; a plugin that wants its upload routes to follow it reads it in Register and puts the value in the route's body.limit.