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:
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| Segment | Description |
|---|---|
YYYY | Four-digit year (e.g. 2026) |
MM | Zero-padded two-digit month (e.g. 03) |
xxxxxxxx | First 8 characters of a UUIDv4 (lowercase hex) |
filename | Original 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
media:
storage: local
local:
base_path: ./uploads
base_url: http://localhost:8080/uploadsS3-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
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
| Name | Max width | Max height |
|---|---|---|
xs | 100 px | 100 px |
sm | 300 px | 300 px |
md | 600 px | 600 px |
lg | 1200 px | 1200 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:
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
| Condition | Error |
|---|---|
Path does not match ^\d{4}/\d{2}/[0-9a-f]{8}-.+$ | invalid media path format: "<path>" |
Resolved absolute path escapes basePath | path escapes base directory: "<path>" |
| File does not exist on disk | Silent 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.