Form File Uploads
S3 file handling, signed URLs, and validation for file fields in forms.
Overview
File fields (signatures, attachments, images) are uploaded to S3 and stored as references in the form.files JSONB column.
Key features:
- Files uploaded to S3 (not stored in database)
- File references stored in
filesJSONB - Time-limited signed URLs for access (15-minute expiry)
- Validation on upload (size, MIME type)
- Immutable after form signing
Upload Flow
1. Client: POST /v1/forms/{id}/files/{fieldKey}
Body: multipart/form-data with file
2. Server validates:
- Form exists and is not signed (409 if signed)
- Field with key exists and has type "file"
- File size ≤ field.max_file_size (default: 10MB)
- File MIME type in field.allowed_file_types (default: image/*, application/pdf)
3. Upload to S3:
Key: {orgID}/forms/{formID}/{fieldKey}/{uuid}.{ext}
4. Store reference in form.files JSONB:
{
"consent_signature": {
"s3_key": "org-1/forms/42/consent_signature/abc123.png",
"name": "signature.png",
"size": 45000,
"mime": "image/png",
"uploaded_at": "2025-01-15T10:00:00Z"
}
}
5. Return signed URL for immediate displayFile Storage Structure
S3 Key Format
{organization_id}/forms/{form_id}/field_{custom_field_id}/{uuid}.{extension}Example:
org-1/forms/42/field_10/550e8400-e29b-41d4-a716-446655440000.png
org-1/forms/42/field_15/7c9e6679-7425-40de-944b-e07fc1f90ae7.pdfWhy this structure?
- Organization-scoped (easy to isolate per tenant)
- Form-scoped (easy to delete all files when form deleted)
- Field-scoped (one file per custom field ID)
- UUID prevents filename collisions
Files JSONB Format
Stored in form.files:
{
"field_10": {
"s3_key": "org-1/forms/42/field_10/abc123.png",
"name": "signature.png",
"size": 45000,
"mime": "image/png",
"uploaded_at": "2025-01-15T10:00:00Z"
},
"field_15": {
"s3_key": "org-1/forms/42/field_15/def456.pdf",
"name": "history.pdf",
"size": 234000,
"mime": "application/pdf",
"uploaded_at": "2025-01-15T10:05:00Z"
}
}Fields:
s3_key— Full S3 object keyname— Original filename (user-provided)size— File size in bytesmime— MIME typeuploaded_at— Upload timestamp
API Endpoints
Upload File
Request:
POST /v1/forms/{id}/files/{fieldKey}
Content-Type: multipart/form-data
<file binary data>Response: 201
{
"data": {
"field_key": "field_20",
"file": {
"s3_key": "org-1/forms/42/field_20/abc123.png",
"name": "signature.png",
"size": 45000,
"mime": "image/png",
"uploaded_at": "2025-01-15T10:00:00Z"
},
"signed_url": "https://s3.amazonaws.com/restartix-platform/org-1/forms/42/field_20/abc123.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&..."
}
}Errors:
409 form_already_signed- Cannot upload files to signed forms400 field_not_found- Field key doesn't exist in form400 field_not_file_type- Field is not a file field400 file_too_large- File exceeds max_file_size400 invalid_file_type- File MIME type not allowed
Get File (Signed URL)
Request:
GET /v1/forms/{id}/files/{fieldKey}Response: 302 Redirects to time-limited signed S3 URL (15-minute expiry)
Access control:
- Patient: own forms only (user_id match)
- Specialist/admin: all forms in org
- Signed URL is short-lived — no persistent URL leaks
Error:
404 file_not_found- No file uploaded for this field
Delete File
Request:
DELETE /v1/forms/{id}/files/{fieldKey}Response: 200
{
"data": {
"field_key": "field_20",
"message": "File deleted successfully"
}
}Business logic:
- Validate form is not signed (409 if signed)
- Remove S3 object
- Clear files JSONB entry
Errors:
409 form_already_signed- Cannot delete files from signed forms
Validation
File Size Validation
Default: 10 MB (10,485,760 bytes)
Override in field definition:
{
"key": "medical_scan",
"label": "Medical Scan",
"type": "file",
"required": true,
"max_file_size": 52428800
}Validation on upload:
func (s *formService) validateFileSize(field FieldDef, fileSize int64) error {
maxSize := int64(10485760) // 10 MB default
if field.MaxFileSize != nil {
maxSize = *field.MaxFileSize
}
if fileSize > maxSize {
return fmt.Errorf("file size %d exceeds maximum of %d bytes", fileSize, maxSize)
}
return nil
}Error response:
{
"error": {
"code": "file_too_large",
"message": "File size exceeds maximum allowed",
"details": {
"file_size": 15728640,
"max_size": 10485760
}
}
}MIME Type Validation
Default allowed types:
["image/png", "image/jpeg", "image/webp", "application/pdf"]Override in field definition:
{
"key": "profile_photo",
"label": "Profile Photo",
"type": "file",
"required": false,
"allowed_file_types": ["image/png", "image/jpeg"]
}Validation on upload:
func (s *formService) validateFileMIME(field FieldDef, mimeType string) error {
allowedTypes := []string{"image/png", "image/jpeg", "image/webp", "application/pdf"}
if field.AllowedFileTypes != nil {
allowedTypes = field.AllowedFileTypes
}
if !slices.Contains(allowedTypes, mimeType) {
return fmt.Errorf("MIME type %q not allowed", mimeType)
}
return nil
}Error response:
{
"error": {
"code": "invalid_file_type",
"message": "File type not allowed",
"details": {
"mime_type": "application/msword",
"allowed_types": ["image/png", "image/jpeg", "image/webp", "application/pdf"]
}
}
}Signed URLs
Files are accessed via time-limited signed URLs (not direct S3 URLs).
Why signed URLs?
- Security: No persistent public URLs
- Expiry: 15-minute expiry prevents URL sharing
- Access control: Only authorized users can generate URLs
- HIPAA compliance: PHI files not publicly accessible
Example signed URL:
https://s3.amazonaws.com/restartix-platform/org-1/forms/42/consent_signature/abc123.png?
X-Amz-Algorithm=AWS4-HMAC-SHA256&
X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250115%2Fus-east-1%2Fs3%2Faws4_request&
X-Amz-Date=20250115T100000Z&
X-Amz-Expires=900&
X-Amz-SignedHeaders=host&
X-Amz-Signature=abc123...Go implementation:
func (s *s3Service) GenerateSignedURL(s3Key string, expiryDuration time.Duration) (string, error) {
req, _ := s.s3Client.GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(s3Key),
})
return req.Presign(expiryDuration)
}Immutability After Signing
Once a form is signed, files become immutable:
Blocked operations:
- Upload new file (409 Conflict)
- Replace existing file (409 Conflict)
- Delete file (409 Conflict)
Still allowed:
- Generate signed URL to view file
Error response:
{
"error": {
"code": "form_already_signed",
"message": "Cannot modify files in a signed form",
"details": {
"form_id": 42,
"signed_at": "2025-01-15T12:00:00Z"
}
}
}File Cleanup
On Form Deletion
When a form is deleted (admin only), all associated files are deleted from S3:
func (s *formService) Delete(ctx context.Context, formID int64) error {
form, err := s.repo.Get(ctx, formID)
if err != nil {
return err
}
if form.Status == "signed" {
return &ConflictError{
Code: "form_already_signed",
Message: "Cannot delete a signed form",
}
}
// Delete all files from S3
for fieldKey, fileRef := range form.Files {
err := s.s3Service.DeleteObject(fileRef.S3Key)
if err != nil {
s.logger.Error("failed to delete S3 object", "s3_key", fileRef.S3Key, "error", err)
// Continue with other files (don't fail entire deletion)
}
}
// Delete form record
return s.repo.Delete(ctx, formID)
}On Organization Deletion
When an organization is deleted, all form files are cascade-deleted:
DELETE FROM organizations WHERE id = 1
→ CASCADE DELETE forms WHERE organization_id = 1
→ Background job deletes all S3 objects matching prefix: org-1/forms/Defaults
When not specified on field:
| Constraint | Default |
|---|---|
max_file_size | 10 MB (10,485,760 bytes) |
allowed_file_types | ["image/png", "image/jpeg", "image/webp", "application/pdf"] |
Security Considerations
Access Control
Files are only accessible if:
- User has access to the form (RLS policies)
- User requests a signed URL (via API)
- Signed URL is used within 15-minute expiry
Direct S3 URLs are NOT public. S3 bucket policy denies all public access.
MIME Type Spoofing
Server validates MIME type based on:
- Content-Type header from client
- File extension
- Magic bytes (file signature detection)
func (s *fileService) detectMIME(file io.Reader) (string, error) {
buffer := make([]byte, 512)
_, err := file.Read(buffer)
if err != nil {
return "", err
}
return http.DetectContentType(buffer), nil
}File Name Sanitization
Original filename is preserved but not used in S3 key:
- S3 key uses UUID (no filename injection attacks)
- Original filename stored in
files.namefor display only
Example Field Definition
{
"key": "consent_signature",
"label": "Signature",
"type": "file",
"required": true,
"private": true,
"max_file_size": 5242880,
"allowed_file_types": ["image/png", "image/jpeg", "image/webp"],
"description": "Please sign the consent form"
}Validation on upload:
- File must be provided (required)
- File size ≤ 5 MB
- MIME type must be PNG, JPEG, or WebP
- Uploaded to S3:
org-1/forms/42/consent_signature/{uuid}.png - Reference stored in
form.files.consent_signature