Skip to content

Media

Stoa provides a pluggable media storage system used for product images and other uploaded assets. Two backends are supported out of the box: local filesystem and S3-compatible object storage.

Storage backends

Both backends implement the same Storage interface:

go
type Storage interface {
    Store(ctx context.Context, filename string, reader io.Reader, size int64) (*StoredFile, error)
    Delete(ctx context.Context, path string) error
    URL(path string) string
}

StoredFile carries the stored path, public URL, file size, and detected MIME type.

Local storage

Files are written to a directory on the server's filesystem. This is the default for single-server deployments.

Path format

Every file stored by LocalStorage.Store() gets a path of the form:

YYYY/MM/xxxxxxxx-filename
SegmentDescription
YYYYFour-digit year (e.g. 2026)
MMZero-padded two-digit month (e.g. 03)
xxxxxxxxFirst 8 characters of a UUIDv4 (lowercase hex)
filenameOriginal filename as provided by the uploader

Example: 2026/03/a1b2c3d4-product-hero.jpg

The date-based layout keeps directories at a manageable size. The UUID prefix makes name collisions practically impossible even for identically named files uploaded in the same month.

Configuration

yaml
media:
  storage: local
  local:
    base_path: ./uploads
    base_url: http://localhost:8080/uploads

S3-compatible storage

S3Storage uses the same YYYY/MM/xxxxxxxx-filename key format. It works with AWS S3 as well as any S3-compatible store (MinIO, Cloudflare R2, etc.).

When a custom endpoint is set, UsePathStyle = true is enabled automatically — this is required by MinIO and most self-hosted S3 alternatives.

Configuration

yaml
media:
  storage: s3
  s3:
    bucket: my-stoa-bucket
    region: eu-central-1
    endpoint: ""                      # leave empty for AWS; set for MinIO/R2
    access_key_id: ""                 # leave empty to use the AWS credential chain
    secret_access_key: ""

If access_key_id and secret_access_key are left empty, the standard AWS credential chain applies (environment variables, ~/.aws/credentials, instance metadata).

File upload

Files are uploaded via POST /api/v1/admin/media (multipart form). Before the file reaches the storage backend, the media handler validates its type using magic byte detection — see File Upload Validation for the full list of allowed MIME types and how SVGs are handled.

Image processing

When ImageMagick's convert binary is available on the server, Stoa automatically generates thumbnails for uploaded images (image/jpeg, image/png, image/gif, image/webp).

Thumbnail presets

NameMax widthMax height
xs100 px100 px
sm300 px300 px
md600 px600 px
lg1200 px1200 px

Thumbnails are scaled down proportionally (the > geometry flag ensures images smaller than the target are never upscaled) and saved at 85% JPEG quality. Each thumbnail is passed through the same Storage backend as the original, so its path follows the same YYYY/MM/xxxxxxxx-filename_<size>.ext pattern.

If convert is not found on $PATH, thumbnail generation is silently skipped and only the original file is stored.

Delete path validation

LocalStorage.Delete() applies a two-stage validation before removing any file from disk. This prevents directory traversal attacks where a crafted path (e.g. ../../etc/passwd) could escape the upload directory.

Stage 1 — Regex format check

The path is matched against ^\d{4}/\d{2}/[0-9a-f]{8}-.+$. Only paths that were generated by Store() can pass this check. Any path that does not match this format — including empty strings, bare filenames, and paths with uppercase hex characters — is rejected immediately, before any filesystem I/O.

Stage 2 — Path containment check

After the regex passes, Stoa resolves the path to an absolute form and verifies it stays inside basePath:

go
absBase, _ := filepath.Abs(basePath)
absPath, _ := filepath.Abs(filepath.Join(basePath, filepath.Clean(relPath)))

if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) {
    return "", fmt.Errorf("path escapes base directory: %q", relPath)
}

This is a defense-in-depth measure. Even a path that passes the regex but contains OS-specific traversal sequences (e.g. backslash sequences on Windows) cannot escape the upload directory after filepath.Clean and filepath.Abs resolve it.

Why S3 is not affected

S3Storage passes the object key directly to the S3 API. S3 keys are opaque strings — the S3 service does not interpret slashes as directory separators in the filesystem sense, and there is no concept of traversal. Path validation applies only to LocalStorage.

Error conditions

ConditionError
Path does not match ^\d{4}/\d{2}/[0-9a-f]{8}-.+$invalid media path format: "<path>"
Resolved absolute path escapes basePathpath escapes base directory: "<path>"
File does not exist on diskSilent success (idempotent)

Both validation failures cause Delete() to return an error. The media handler translates this to 400 Bad Request.

Related

The Security page summarises both the upload validation and delete path validation in the security context.

Released under the APACHE 2.0 License.