Files · S3

Files · S3


Stores uploads in AWS S3, Cloudflare R2, or a self-hosted store that speaks the S3 API, instead of on the application's own disk. Public files get a direct, CDN-friendly URL; private files stay private and are proxied through the API so authorisation is still checked on every read.

Enable it

It depends on the Files module, which owns the upload endpoints. The package brings Files in with it, so one reference is enough; enable both:

dotnet add package BarakoCMS.Files.S3
builder.Services.AddBarakoCMS(builder.Configuration);

var app = builder.Build();
app.UseBarakoCMS();

The package reference plus a restart is the install. AddBarakoCMS finds every module in the application's dependency context, and BarakoCMS:Modules:Enabled decides which of them run (BarakoCMS__Modules__Enabled=Files,Files.S3). Unset, every referenced module runs and the API logs one warning saying so. To name it by hand instead, put modules.Add(new BarakoCMS.Files.S3.S3FilesModule()) in the AddBarakoCMS callback; discovery skips a type the host already added. See MODULES.md in the repository.

Configuration

{
  "Files": {
    "S3": {
      "Bucket": "my-bucket",
      "Region": "us-east-1",
      "AccessKey": "...",
      "SecretKey": "...",
      "ServiceUrl": null,
      "ForcePathStyle": false,
      "PublicBaseUrl": null,
      "UsePublicReadAcl": false
    }
  }
}

Upgrading to 5.0

UsePublicReadAcl now defaults to false. A deployment that never set it, on an AWS bucket with ACLs enabled that relied on the ACL to make public files readable, stops getting public-read objects on upgrade: files uploaded after it are not readable at their public URL. Set Files__S3__UsePublicReadAcl=true to keep the old behaviour, or better, grant read on public/* or serve through CloudFront as below. Files already uploaded keep their ACL. Every other setup (a new AWS bucket, R2, SeaweedFS, Garage) either refused the ACL or ignored it, and is unaffected.

The section is Files:S3. With no Files:S3:Bucket set the provider stays dormant and the default storage keeps serving, so a typo here shows up as "uploads still work but nothing reaches the bucket" rather than as an error.

Key Notes
ServiceUrl Set for R2 or a self-hosted store; leave null for AWS
ForcePathStyle Usually true for self-hosted stores
PublicBaseUrl Serve public files from your CDN domain. The key is appended, so a public file's URL is {PublicBaseUrl}/public/{name}
UsePublicReadAcl Defaults to false (true before 5.0). Set it to true only for an AWS bucket with ACLs enabled that serves public files by ACL. Otherwise grant read on public/* as below, not on the bucket
MaxErrorRetry Unset is 2, or fewer on a short lease. Tries after the first the SDK makes for one call, 0 to 10. The SDK's own default is 4
TimeoutSeconds Unset is 45, or less on a short lease. How long one try may take, a body upload included

Keys belong in environment variables or a secret store, never in a checked-in appsettings.json.

Choosing a bucket per tenant

With no Files:S3 key set, this module stays dormant for configuration and lets each tenant pick a bucket through /api/settings/storage (see the Files module's README). Setting any key here makes that choice read-only for every tenant. A bucket chosen through the API may only be at a public address unless Modules:Files.S3:AllowPrivateServiceUrl is true.

Retries and the job lease

A file read or write inside a background job or a workflow action has to finish inside its lease, or another node runs the work again. The longest one call can take is every try running to TimeoutSeconds plus a wait of up to 30 seconds between tries (the SDK's legacy retry mode caps a wait at 30 seconds, its standard mode at 20): 195 seconds with the defaults. The limit is 80% of the shorter of the workflow runner's 5 minute lease and Jobs:LeaseSeconds. An unset value gives way to fit it: first fewer retries, then a shorter timeout. Values set by hand are used as they are, and the host refuses to start when they cannot fit. MaxErrorRetry is always set on the client, so AWS_MAX_ATTEMPTS no longer changes it. AWS_RETRY_MODE=adaptive can also wait for the SDK's own rate limiter, which this bound does not count.

Key layout and public access

Visibility is in the key. A public file is stored as public/{name} and a private one as private/{name}, and a resized copy sits under the prefix for the visibility of the file it came from. The name is random and the extension comes from the checked content type; the upload's isPublic flag picks the prefix and nothing else from the request is in the key.

That prefix is what makes direct public URLs safe. A bucket policy and a CDN origin grant by bucket or by key prefix, never by the isPublic flag in the database. Granted on the whole bucket, they serve a private file to anyone who has its key. Granted on public/*, they serve public files and nothing else.

On AWS S3 there are two ways to do that. Neither is run by this module's tests, which check where objects land and not what a bucket serves; the policies below are the shape AWS documents.

Direct bucket URLs. With UsePublicReadAcl set to false, this is the bucket policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicFilesOnly",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-bucket/public/*"
    }
  ]
}

Do not grant s3:GetObject on arn:aws:s3:::my-bucket/*, and do not grant s3:ListBucket to anonymous callers. Set PublicBaseUrl to the bucket's URL (https://my-bucket.s3.eu-west-1.amazonaws.com).

The policy alone is not enough on a new bucket. AWS turns on all four Block Public Access settings when a bucket is created, and two of them stop this route: BlockPublicPolicy rejects a policy with "Principal": "*" when it is saved, and RestrictPublicBuckets refuses anonymous reads even with such a policy in place. Both have to be off, on the bucket and on the account, for the direct URL to work. BlockPublicAcls and IgnorePublicAcls can stay on.

CloudFront, the one to prefer. It needs none of the four settings changed: the bucket stays private and only the distribution reads the prefix. Create the distribution with the bucket as its origin and an origin access control, leave the origin path empty so the URL path is the key (an origin path of /public would look for public/public/{name}), and scope the bucket policy to the distribution and the prefix:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudFrontPublicFilesOnly",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-bucket/public/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
        }
      }
    }
  ]
}

Set PublicBaseUrl to the distribution's domain and UsePublicReadAcl to false. A request for /private/... through the distribution is refused by S3, because the policy does not cover it.

Where the only grant on offer covers the whole bucket (an R2 bucket made public in the dashboard is public as a whole), there is no safe way to give public files a direct URL from the same bucket as private ones. Leave PublicBaseUrl unset there and public files are streamed through GET /api/public/files/{id}.

The storage refuses a write whose key and visibility disagree: a private object under public/, or a public one under private/. The key is read loosely for that check, so /public/x, ./public/x, a/../public/x, public\x and Public/x are refused for a private object too. A host writing its own IFileStorage can ask the same question with FileKeys.Contradicts.

Files stored before the prefixes

A file uploaded before BarakoCMS.Files 4.4.0 keeps the key on its row, at the bucket root with no prefix, and nothing moves it. The API reads, resizes and deletes it by that key as before, and a new resize of it is stored under the prefix for its visibility.

Those files are the reason not to narrow an existing bucket's grant without looking first. A grant on public/* does not cover a root key, so an old private file stops being reachable at the bucket, which is the point, and so does an old public one: its stored publicUrl then answers 403, and GET /api/public/files/{id} still redirects to it. Upload those public files again before narrowing the grant. While the grant covers the whole bucket, old and new private files alike are readable by anyone who has a key.

S3-compatible stores

Store Notes
AWS S3 Leave ServiceUrl null and set Region. UsePublicReadAcl only works on a bucket with ACLs enabled, and new buckets have them disabled, so prefer CloudFront or the policy on public/* above.
Cloudflare R2 No object ACLs, so set UsePublicReadAcl to false. A bucket made public in the R2 dashboard is public as a whole, private files included: see "Key layout and public access".
SeaweedFS Apache-2.0. The module's own tests run against it. Does not apply an ACL sent with an upload, so set UsePublicReadAcl to false. Anonymous read granted on the whole bucket covers private files too: see "Key layout and public access".
Garage AGPL-3.0. No object ACLs, so treat it like R2 and set UsePublicReadAcl to false.
MinIO Unmaintained. The upstream repository is archived and gets no security patches, so it is not recommended for new deployments.

Part of barakoCMS

This is an optional module for barakoCMS, an open-source headless CMS for .NET 10. Every module is published under the barakocms-module tag, so a single search on nuget.org returns the whole set.

Contributions are welcome — including a module icon or other design work. See CONTRIBUTING.md.

Licensed under MIT.

If barakoCMS is useful to you, a star on the repository helps other people find it.