Form File Uploads
S3 file handling, signed URLs, and validation for file fields in forms.
NOT BUILT — reconciled 2026-08-02
No forms / form_templates / custom_fields table exists, no Go domain, no routes, and zero forms.* permission rows in any migration. Ships in migrations 000042 + 000043. See index.md for the full correction list — the load-bearing ones are: consents, not user_consents; no users table / no user_id (actors are created_by_principal_id / signed_by_principal_id, the subject is patient_profile_id); UUID PKs, not integers; custom-field keys unique per org, never globally; and the instance carries a fields JSONB snapshot + template_version frozen at creation, which is the single most important structural fix in the port.
The S3 surface is already registered and unused: s3.SurfaceFormsUpload in internal/integration/s3/surfaces.go (pdf / png / jpeg / webp, 10 MB cap). It was reserved for exactly this feature. Reads are presigned and short-lived (15 min) plus ValidateOrgScope — never a raw, unsigned, never-expiring URL to a patient's medical document.
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