Beside operations for your data, there are mutations related to S3 compatible storage. Contember itself doesn't store your files, but it can help you with signing URLs for uploading (or reading) those files, and it can delete them for you.

S3 server

In Contember development stack, there is a bundled SeaweedFS server, which is an S3 compatible storage server, therefore you don't have to setup anything on localhost as it is running out of box. Set DEFAULT_S3_PROVIDER to minio for it — the provider name selects path-style addressing without object ACL, which is what SeaweedFS and other MinIO-compatible servers need. For production see our guide

Signing upload URL

Use GraphQL generateUploadUrl mutation to generate a presigned S3 upload URL. This is a secure way how to access your S3 bucket without exposing credentials to a client application, because only Contember server knows a secret key, using which it can sign one time URL.

Execute following mutation in your application:

mutation {
  signedUpload: generateUploadUrl(contentType: "image/jpeg") {
    url
    publicUrl
    method
    headers {
        key
        value
    }
  }
}

The url and other fields can be used to construct a request, which uploads a file directly to S3 storage from an application:

await fetch(
  signedUpload.url,
  {
    method: signedUpload.method,
    headers: Object.fromEntries(signedUpload.headers.map(({ key, value }) => [key, value])),
    body: content,
  }
)

S3 diagram

generateUploadUrl mutation has few optional arguments

  • expiration (default 3600) - URL expiration in seconds
  • acl (default corresponds S3 provider, usually it is "PUBLIC_READ") - object ACL
    • PUBLIC_READ: anyone who knows public URL is allowed to read an object
    • PRIVATE: object is not accessible using its public URL
    • NONE: explicit object ACL is not set, leaving ACL to bucket policies
Note

Some S3 providers does not support object level ACL, so this argument will not be available at all.

Signing read url

When you set an object ACL to PRIVATE, you can use this mutation to sign a read URL:

mutation {
  generateReadUrl(objectKey: "images/5b934fe1-0e30-4761-9e00-d4bfabbddf34.png") {
    url
  }
}
Tip

If you store public URL instead of object key, don't worry. Contember will recognize it and parses an object key from it.

You can also optionally set an expiration of the URL (default 3600 seconds)

Deleting an object

Available since 2.2

Unlike an upload or a read, a delete moves no bytes through your application, so there is no URL to sign — Contember signs the request and calls the storage itself:

mutation {
  deleteS3Object(objectKey: "images/5b934fe1-0e30-4761-9e00-d4bfabbddf34.png") {
    objectKey
  }
}

Just like generateReadUrl, this mutation accepts a public URL instead of an object key. It requires a delete operation in an ACL, which no role has by default — see Allowing a delete. It is idempotent — deleting an object which does not exist succeeds. When the storage rejects the request, an error is returned.

Note

Deleting an object does not purge a CDN cache. If you delete a file because it must stop being served (e.g. a takedown or an erasure request), purge the cache of your CDN as well.

Configuring S3 ACL

To use S3 functionality, each role in the ACL schema must have defined ACL rules. This is the most simple rule, which allows reading and uploading any key:

const adminRole = {
  s3: {
    '**': {
      read: true,
      upload: true,
    }
  },
  // ... variables, stage, entities...
}

In a key you define a glob-like pattern for an S3 object key and in a value you define allowed operations (read, upload and delete, the last one available since 2.2 and covered in Allowing a delete). We use picomatch library for a pattern matching.

Example patterns

  • ** - matches anything
  • images/** - matches any object with an images key prefix
  • **.jpg - matches any object with a jpg extension

ACL evaluation

Contember will try to find any rule for an object key, which allows given operation. This means that if you first define a specific rule for private/**, which does NOT allow an upload, and later you define a generic rule ** , which allows upload, then this rule will override previous one.

Note

read option only affects generateReadUrl mutation and does not affect upload ACL (PUBLIC_READ, PRIVATE) nor public read URL in any way.

Allowing a delete

delete is opt-in. It is a separate operation, not implied by upload, and no role has it unless you grant it — including the built-in admin and content_admin, which get read and upload on ** but not delete. Deleting an object is irreversible, so a role gets that capability only when you say so.

For your own roles, grant it like any other option:

import { c } from '@contember/schema-definition'

export const editorRole = c.createRole('editor', {
  s3: {
    'images/**': {
      read: true,
      upload: true,
      delete: true,
    },
  },
})

The built-in roles have no definition of their own, so granting them a delete means overriding their s3 section in the createSchema callback. Unlike content and system, this section is not merged with the built-in one — what you define replaces it completely, so repeat read and upload:

import { createSchema } from '@contember/schema-definition'
import * as model from './model'

export default createSchema(model, schema => ({
  ...schema,
  acl: {
    ...schema.acl,
    roles: {
      ...schema.acl.roles,
      admin: { ...schema.acl.roles.admin, s3: { '**': { read: true, upload: true, delete: true } } },
    },
  },
}))

Only the keys you set are overridden, so the entity permissions, stages and variables of the built-in role stay untouched.

Caution

Do not use c.createRole('admin', …) for this. A role defined that way also carries its own entities, which replaces the built-in allow-all entity permissions — the role keeps its name but loses access to your data.