Skip to content

File Storage

FraiseQL provides file management capabilities with local filesystem and S3 storage backends.

File storage features:

  • Multipart file uploads via GraphQL
  • File type validation and size limits
  • Image processing (resize, format conversion)
  • S3-compatible storage backends
  • Signed URLs for secure access
[files]
enabled = true
backend = "local"
[files.local]
path = "/var/fraiseql/uploads"
url_prefix = "/files"
[files]
enabled = true
backend = "s3"
[files.s3]
bucket = "my-app-files"
region = "us-east-1"
access_key_id = "${AWS_ACCESS_KEY_ID}"
secret_access_key = "${AWS_SECRET_ACCESS_KEY}"
# Optional: custom endpoint for S3-compatible storage
endpoint = "https://s3.example.com"
# URL generation
url_style = "path" # or "virtual_hosted"
[files.s3]
bucket = "uploads"
endpoint = "http://minio:9000"
access_key_id = "${MINIO_ACCESS_KEY}"
secret_access_key = "${MINIO_SECRET_KEY}"
force_path_style = true
import fraiseql
from fraiseql import Upload
@fraiseql.type
class FileInfo:
id: fraiseql.ID
filename: str
mime_type: str
size: int
url: str
@fraiseql.mutation(sql_source="fn_upload_file", operation="CREATE")
def upload_file(
file: Upload,
folder: str = "uploads"
) -> FileInfo:
"""Upload a file and return its info."""
pass
const uploadFile = async (file) => {
const formData = new FormData();
// GraphQL multipart request format
formData.append('operations', JSON.stringify({
query: `
mutation UploadFile($file: Upload!) {
uploadFile(file: $file) {
id
filename
url
}
}
`,
variables: { file: null }
}));
formData.append('map', JSON.stringify({ '0': ['variables.file'] }));
formData.append('0', file);
const response = await fetch('/graphql', {
method: 'POST',
body: formData
});
return response.json();
};
[files.limits]
max_file_size_mb = 50
max_request_size_mb = 100
max_files_per_request = 10
[files.validation]
# Allowed MIME types
allowed_types = [
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
"text/csv"
]
# Or allow by extension
allowed_extensions = [".jpg", ".png", ".pdf", ".csv"]
# Validate actual content, not just extension
validate_content = true
from typing import Annotated
@fraiseql.type
class Profile:
avatar: Annotated[
str,
fraiseql.field(
file_upload=True,
max_size_mb=5,
allowed_types=["image/jpeg", "image/png"]
)
]
[files.images]
enabled = true
# Generate thumbnails
[files.images.thumbnails]
enabled = true
sizes = [
{ name = "small", width = 150, height = 150 },
{ name = "medium", width = 300, height = 300 },
{ name = "large", width = 600, height = 600 }
]
fit = "cover" # cover, contain, fill
# Format conversion
[files.images.conversion]
enabled = true
format = "webp"
quality = 85
query {
user(id: "123") {
avatar # Original
avatarSmall: avatar(size: "small")
avatarMedium: avatar(size: "medium")
}
}

Secure, time-limited access to files.

[files.signed_urls]
enabled = true
expiry_seconds = 3600 # 1 hour
signing_key = "${FILE_SIGNING_KEY}"
query {
getFileUrl(fileId: "file-123", expiresIn: 300) {
url
expiresAt
}
}
[files.s3]
use_presigned_urls = true
presigned_url_expiry_seconds = 3600
CREATE TABLE tb_file (
pk_file BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
storage_path TEXT NOT NULL,
storage_backend TEXT NOT NULL DEFAULT 'local',
checksum TEXT, -- SHA-256
metadata JSONB DEFAULT '{}',
uploaded_by UUID REFERENCES tb_user(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_file_uploaded_by ON tb_file(uploaded_by);
CREATE INDEX idx_file_mime_type ON tb_file(mime_type);
@fraiseql.type
class File:
id: fraiseql.ID
filename: str
original_filename: str
mime_type: str
size_bytes: int
url: str
thumbnails: list[Thumbnail] | None
uploaded_by: User | None
created_at: fraiseql.DateTime
[files]
download_path = "/files"

Access files at:

GET /files/{file-id}/{filename}
GET /files/{file-id}/download # Force download header
[files.streaming]
enabled = true
chunk_size_kb = 64
[files.scanning]
enabled = true
scanner = "clamav"
[files.scanning.clamav]
host = "clamav"
port = 3310
timeout_seconds = 30
# Action on infected file
on_infected = "reject" # or "quarantine"
MetricDescription
fraiseql_file_uploads_totalTotal uploads
fraiseql_file_upload_bytes_totalBytes uploaded
fraiseql_file_downloads_totalTotal downloads
fraiseql_file_download_bytes_totalBytes downloaded
fraiseql_file_upload_duration_msUpload latency
fraiseql_file_validation_failures_totalValidation failures
# Development
[files]
backend = "local"
# Production
[files]
backend = "s3"
[files.validation]
validate_content = true
allowed_types = ["image/jpeg", "image/png", "application/pdf"]
max_file_size_mb = 10
[files.signed_urls]
enabled = true
expiry_seconds = 300 # Short expiry for security
[files.scanning]
enabled = true
scanner = "clamav"
  1. Check file size limits
  2. Verify MIME type is allowed
  3. Check storage permissions
  4. Review virus scan results
  1. Verify credentials
  2. Check bucket policy
  3. Verify IAM permissions
  4. Check CORS configuration
  1. Enable streaming
  2. Check network bandwidth
  3. Use regional S3 endpoint
  4. Consider multipart uploads

Security

Security — File access control

Deployment

Deployment — Production file storage