Security
Security — File access control
FraiseQL provides file management capabilities with local filesystem and S3 storage backends.
File storage features:
[files]enabled = truebackend = "local"
[files.local]path = "/var/fraiseql/uploads"url_prefix = "/files"[files]enabled = truebackend = "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 storageendpoint = "https://s3.example.com"
# URL generationurl_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[files.s3]bucket = "my-space"region = "nyc3"endpoint = "https://nyc3.digitaloceanspaces.com"access_key_id = "${DO_SPACES_KEY}"secret_access_key = "${DO_SPACES_SECRET}"[files.s3]bucket = "my-bucket"endpoint = "https://account-id.r2.cloudflarestorage.com"access_key_id = "${R2_ACCESS_KEY}"secret_access_key = "${R2_SECRET_KEY}"import fraiseqlfrom fraiseql import Upload
@fraiseql.typeclass 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.""" passconst 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();};import httpx
def upload_file(file_path: str): with open(file_path, 'rb') as f: files = { 'operations': (None, json.dumps({ 'query': ''' mutation UploadFile($file: Upload!) { uploadFile(file: $file) { id url } } ''', 'variables': {'file': None} })), 'map': (None, json.dumps({'0': ['variables.file']})), '0': (file_path, f, 'application/octet-stream') }
response = httpx.post('http://localhost:8080/graphql', files=files) return response.json()async function uploadFile(file: File): Promise<UploadResult> { const formData = new FormData();
formData.append('operations', JSON.stringify({ query: ` mutation UploadFile($file: Upload!) { uploadFile(file: $file) { id 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();}
interface UploadResult { data?: { uploadFile: { id: string; url: string; }; };}package main
import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os")
func uploadFile(filePath string) (map[string]interface{}, error) { file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close()
body := &bytes.Buffer{} writer := multipart.NewWriter(body)
operations := map[string]interface{}{ "query": `mutation UploadFile($file: Upload!) { uploadFile(file: $file) { id url } }`, "variables": map[string]interface{}{"file": nil}, } opsJSON, _ := json.Marshal(operations) writer.WriteField("operations", string(opsJSON))
mapping := map[string][]string{"0": {"variables.file"}} mapJSON, _ := json.Marshal(mapping) writer.WriteField("map", string(mapJSON))
part, _ := writer.CreateFormFile("0", filePath) io.Copy(part, file) writer.Close()
req, _ := http.NewRequest("POST", "http://localhost:8080/graphql", body) req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close()
var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) return result, nil}[files.limits]max_file_size_mb = 50max_request_size_mb = 100max_files_per_request = 10[files.validation]# Allowed MIME typesallowed_types = [ "image/jpeg", "image/png", "image/webp", "application/pdf", "text/csv"]
# Or allow by extensionallowed_extensions = [".jpg", ".png", ".pdf", ".csv"]
# Validate actual content, not just extensionvalidate_content = truefrom typing import Annotated
@fraiseql.typeclass 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 = truesizes = [ { 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 = trueformat = "webp"quality = 85query { user(id: "123") { avatar # Original avatarSmall: avatar(size: "small") avatarMedium: avatar(size: "medium") }}Secure, time-limited access to files.
[files.signed_urls]enabled = trueexpiry_seconds = 3600 # 1 hoursigning_key = "${FILE_SIGNING_KEY}"query { getFileUrl(fileId: "file-123", expiresIn: 300) { url expiresAt }}[files.s3]use_presigned_urls = truepresigned_url_expiry_seconds = 3600CREATE 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.typeclass 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 = truechunk_size_kb = 64[files.scanning]enabled = truescanner = "clamav"
[files.scanning.clamav]host = "clamav"port = 3310timeout_seconds = 30
# Action on infected fileon_infected = "reject" # or "quarantine"| Metric | Description |
|---|---|
fraiseql_file_uploads_total | Total uploads |
fraiseql_file_upload_bytes_total | Bytes uploaded |
fraiseql_file_downloads_total | Total downloads |
fraiseql_file_download_bytes_total | Bytes downloaded |
fraiseql_file_upload_duration_ms | Upload latency |
fraiseql_file_validation_failures_total | Validation failures |
# Development[files]backend = "local"
# Production[files]backend = "s3"[files.validation]validate_content = trueallowed_types = ["image/jpeg", "image/png", "application/pdf"]max_file_size_mb = 10[files.signed_urls]enabled = trueexpiry_seconds = 300 # Short expiry for security[files.scanning]enabled = truescanner = "clamav"Security
Security — File access control
Deployment
Deployment — Production file storage