Skip to main content

Storage API

Heimdall provides S3-compatible storage for file uploads, downloads, and management. It supports any S3-compatible provider including AWS S3, MinIO, Cloudflare R2, and DigitalOcean Spaces.

Overview

The Storage API allows users to upload, download, and manage files with configurable validation rules per category. It supports presigned URLs for direct browser uploads/downloads and includes full audit logging.

Key Features

  • S3-compatible - Works with any S3-compatible storage provider
  • Presigned URLs - Generate upload/download URLs for direct browser access
  • Category-based limits - Configurable file size and MIME type restrictions per category
  • Permission-based access - RBAC permissions for fine-grained control
  • Audit logging - All file operations are logged with user and request context

Required Permissions

Storage operations require specific RBAC permissions:

PermissionDescription
storage:readView storage files, metadata, and list files
storage:writeUpload files and get presigned upload URLs
storage:editReserved — defined but not currently enforced (no metadata-edit endpoint yet)
storage:downloadDownload files and get presigned download URLs
storage:deleteDelete files from storage

Upload Categories

Files are organized into categories with configurable limits:

CategoryDefault Max SizeDefault Allowed Types
images5 MBimage/jpeg, image/png, image/webp, image/gif, image/svg+xml
documents25 MBapplication/pdf, text/plain, text/markdown, application/json, application/xml
videos500 MBvideo/mp4, video/webm, video/quicktime, video/x-msvideo, video/x-matroska
general100 MB/ (all types)

These limits are configurable in the API configuration.

REST API Endpoints

Get Storage Limits

Get the upload limits for all categories.

GET /v1/storage/limits
Authorization: Bearer YOUR_TOKEN

Response:

{
"enabled": true,
"bucket": "heimdall",
"limits": {
"images": {
"maxSizeMb": 5,
"maxSizeBytes": 5242880,
"allowedTypes": ["image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"]
},
"documents": {
"maxSizeMb": 25,
"maxSizeBytes": 26214400,
"allowedTypes": ["application/pdf", "text/plain", "text/markdown", "application/json", "application/xml"]
},
"videos": {
"maxSizeMb": 500,
"maxSizeBytes": 524288000,
"allowedTypes": ["video/mp4", "video/webm", "video/quicktime", "video/x-msvideo", "video/x-matroska"]
},
"general": {
"maxSizeMb": 100,
"maxSizeBytes": 104857600,
"allowedTypes": ["*/*"]
}
},
"presignedExpirySeconds": 3600,
"_links": {
"self": "https://api.example.com/v1/storage/limits"
}
}

Upload File

Upload a file directly to storage.

POST /v1/storage/upload
Authorization: Bearer YOUR_TOKEN
Content-Type: multipart/form-data

Form Fields:

FieldTypeRequiredDescription
filefileYesThe file to upload
categorystringYesCategory: "images", "documents", "videos", or "general"
keystringNoCustom storage key (auto-generated if not provided)

Response:

{
"key": "images/user123/profile.jpg",
"url": "https://storage.example.com/images/user123/profile.jpg",
"contentType": "image/jpeg",
"sizeBytes": 245632,
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"bucket": "heimdall",
"_links": {
"self": "https://api.example.com/v1/storage/upload",
"download": "https://api.example.com/v1/storage/download/images/user123/profile.jpg"
}
}

Get Presigned Upload URL

Get a presigned URL for direct browser upload.

POST /v1/storage/upload/presigned
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

Request Body:

{
"filename": "profile.jpg",
"contentType": "image/jpeg",
"category": "images"
}

Optional fields: customKey (custom storage key) and expirySeconds (URL expiry, default 3600).

Response:

{
"url": "https://storage.example.com/images/user123/abc123.jpg?X-Amz-Signature=...",
"key": "images/user123/abc123.jpg",
"method": "PUT",
"expiresAt": "2026-01-24T12:00:00Z",
"_links": {
"self": "https://api.example.com/v1/storage/upload/presigned"
}
}

Download File

Download a file from storage.

GET /v1/storage/download/{key}
Authorization: Bearer YOUR_TOKEN

Path Parameters:

ParameterTypeDescription
keystringThe storage key (URL-encoded if contains special characters)

Response: The file content with appropriate Content-Type header.

Get Presigned Download URL

Get a presigned URL for direct browser download.

GET /v1/storage/download/{key}/presigned
Authorization: Bearer YOUR_TOKEN

Query Parameters:

ParameterTypeDefaultDescription
expirySecondsinteger3600URL expiration time in seconds. Falls back to the configured presigned_expiry_seconds when omitted.

Response:

{
"url": "https://storage.example.com/images/user123/profile.jpg?X-Amz-Signature=...",
"key": "images/user123/profile.jpg",
"method": "GET",
"expiresAt": "2026-01-24T13:00:00Z",
"_links": {
"self": "https://api.example.com/v1/storage/download/images/user123/profile.jpg/presigned"
}
}

Get File Metadata

Get metadata for a stored file.

GET /v1/storage/{key}/metadata
Authorization: Bearer YOUR_TOKEN

Response:

{
"key": "images/user123/profile.jpg",
"sizeBytes": 245632,
"contentType": "image/jpeg",
"lastModified": "2026-01-24T10:30:00Z",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"bucket": "heimdall",
"_links": {
"self": "https://api.example.com/v1/storage/images/user123/profile.jpg/metadata",
"download": "https://api.example.com/v1/storage/download/images/user123/profile.jpg"
}
}

List Files

List files with optional prefix filtering.

GET /v1/storage/list
Authorization: Bearer YOUR_TOKEN

Query Parameters:

ParameterTypeDefaultDescription
prefixstring-Filter by key prefix (e.g., "images/user123/")
delimiterstring-Delimiter for hierarchical listing (usually "/")
maxKeysinteger1000Maximum number of results (S3 default when omitted)
continuationTokenstring-Token for pagination

Response:

{
"files": [
{
"key": "images/user123/profile.jpg",
"sizeBytes": 245632,
"lastModified": "2026-01-24T10:30:00Z",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\""
}
],
"prefixes": [],
"isTruncated": false,
"continuationToken": null,
"bucket": "heimdall",
"prefix": "images/user123/",
"_links": {
"self": "https://api.example.com/v1/storage/list"
}
}

Delete File

Delete a file from storage. This endpoint:

  • Verifies the file exists before deletion
  • Deletes the file from S3 storage
  • Removes the file metadata from the database
  • Logs an audit event (file_deleted)

Requires storage:delete permission.

DELETE /v1/storage/{key}
Authorization: Bearer YOUR_TOKEN

Path Parameters:

ParameterTypeDescription
keystringThe storage key (URL-encoded if contains special characters)

Response (200 OK):

{
"message": "File deleted successfully"
}

Error Responses:

StatusDescription
401Unauthorized - invalid or missing token
403Forbidden - missing storage:delete permission
404File not found
500Storage operation failed

GraphQL API

Queries

storageLimits

Get upload limits for all categories.

query {
storageLimits {
images {
maxSizeMb
allowedTypes
}
documents {
maxSizeMb
allowedTypes
}
videos {
maxSizeMb
allowedTypes
}
general {
maxSizeMb
allowedTypes
}
}
}

presignedDownloadUrl

Get a presigned download URL.

query {
presignedDownloadUrl(key: "images/user123/profile.jpg", expirySeconds: 3600) {
url
key
expiresAt
}
}

fileMetadata

Get file metadata.

query {
fileMetadata(key: "images/user123/profile.jpg") {
key
sizeBytes
contentType
lastModified
etag
}
}

listFiles

List files with pagination.

query {
listFiles(input: { prefix: "images/user123/", maxKeys: 100 }) {
files {
key
sizeBytes
lastModified
etag
}
prefixes
isTruncated
continuationToken
bucket
prefix
}
}

Mutations

getPresignedUploadUrl

Get a presigned URL for uploading.

mutation {
getPresignedUploadUrl(input: {
filename: "profile.jpg"
contentType: "image/jpeg"
category: "images"
}) {
url
key
expiresAt
}
}

deleteFile

Delete a file from storage. This mutation:

  • Verifies the file exists before deletion
  • Deletes the file from S3 storage
  • Removes the file metadata from the database
  • Logs an audit event (file_deleted)

Requires storage:delete permission.

mutation {
deleteFile(key: "images/user123/profile.jpg")
}

Response: Returns true on success.

Errors:

  • "Authentication required" - No valid auth token
  • "File not found" - The specified key doesn't exist
  • "Failed to delete file: ..." - S3 deletion failed

Audit Events

All storage operations are logged:

EventDescription
file_createdFile uploaded to storage
file_downloadedReserved — constant defined but not currently emitted
file_editedReserved — constant defined but not currently emitted
file_deletedFile deleted from storage

Configuration

Storage is configured in the API configuration file:

[storage]
enabled = true
endpoint = "http://localhost:9000" # S3-compatible endpoint
region = "us-east-1"
access_key = "your-access-key"
secret_key = "your-secret-key"
bucket = "heimdall"
path_style = true # Required for MinIO
public_url = "" # Optional CDN URL

[storage.upload_limits.images]
max_size_mb = 5
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/gif"]

[storage.upload_limits.documents]
max_size_mb = 25
allowed_types = ["application/pdf", "text/plain", "application/json"]

[storage.upload_limits.videos]
max_size_mb = 500
allowed_types = ["video/mp4", "video/webm", "video/quicktime", "video/x-msvideo"]

[storage.upload_limits.general]
max_size_mb = 100
allowed_types = ["*/*"]

Provider-Specific Configuration

MinIO (Local Development)

[storage]
endpoint = "http://localhost:9000"
region = "us-east-1"
path_style = true

Cloudflare R2

[storage]
endpoint = "https://<account_id>.r2.cloudflarestorage.com"
region = "auto"
path_style = false
public_url = "https://pub-xxx.r2.dev"

DigitalOcean Spaces

[storage]
endpoint = "https://<region>.digitaloceanspaces.com"
region = "<region>"
path_style = false
public_url = "https://<space>.<region>.cdn.digitaloceanspaces.com"

AWS S3

[storage]
endpoint = "https://s3.<region>.amazonaws.com"
region = "<region>"
path_style = false

Storage File Metadata

Track who uploaded what files and when with file metadata queries.

REST API

List Storage Files (Admin)

List all storage files with optional filtering. Requires storage:read permission.

GET /v1/storage/files
Authorization: Bearer YOUR_TOKEN

Query Parameters:

ParameterTypeDefaultDescription
pageinteger1Page number (1-indexed)
limitinteger20Items per page (max 100)
user_idstring-Filter by user ID
categorystring-Filter by category (images, documents, videos, general)
content_typestring-Filter by MIME type
key_prefixstring-Filter by key prefix
filename_searchstring-Search by filename (partial match)
start_datestring-Filter by start date (ISO 8601)
end_datestring-Filter by end date (ISO 8601)
sort_fieldstringcreated_atSort by: created_at, size_bytes, original_filename
sort_directionstringdescSort direction: asc or desc

Response:

{
"files": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "user123",
"key": "images/user123/profile.jpg",
"originalFilename": "profile.jpg",
"contentType": "image/jpeg",
"category": "images",
"sizeBytes": 245632,
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"bucket": "heimdall",
"createdAt": "2026-01-24T10:30:00Z"
}
],
"totalCount": 150,
"page": 1,
"limit": 20,
"totalPages": 8
}

Get File Info by Key

Get metadata for a specific file by its storage key.

GET /v1/storage/files/{key}/info
Authorization: Bearer YOUR_TOKEN

List My Files

List storage files uploaded by the current authenticated user.

GET /v1/storage/files/me
Authorization: Bearer YOUR_TOKEN

GraphQL API

Query: storageFiles

List all storage files with filters (admin, requires storage:read).

query {
storageFiles(input: {
category: "images"
filenameSearch: "profile"
limit: 20
sortField: "created_at"
sortDirection: "desc"
}) {
files {
id
key
originalFilename
contentType
sizeBytes
userId
createdAt
}
totalCount
totalPages
}
}

Query: storageFileByKey

Get file metadata by storage key.

query {
storageFileByKey(key: "images/user123/profile.jpg") {
id
userId
originalFilename
sizeBytes
createdAt
}
}

Query: myStorageFiles

List current user's files.

query {
myStorageFiles(
category: "images"
limit: 20
) {
files {
key
originalFilename
sizeBytes
createdAt
}
totalCount
}
}

Error Responses

StatusCodeDescription
400NO_FILE / INVALID_CATEGORYNo file provided, or unknown upload category
401UNAUTHORIZEDAuthentication required
403FORBIDDENInsufficient permissions
404NOT_FOUNDFile not found
413FILE_TOO_LARGEFile exceeds the category size limit
415INVALID_MIME_TYPEMIME type not allowed for the category
500INTERNAL_ERRORStorage operation failed
503STORAGE_DISABLEDStorage service is not enabled