Skip to content

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 files JSONB
  • 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 display

File 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.pdf

Why 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:

json
{
  "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 key
  • name — Original filename (user-provided)
  • size — File size in bytes
  • mime — MIME type
  • uploaded_at — Upload timestamp

API Endpoints

Upload File

Request:

http
POST /v1/forms/{id}/files/{fieldKey}
Content-Type: multipart/form-data

<file binary data>

Response: 201

json
{
  "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 forms
  • 400 field_not_found - Field key doesn't exist in form
  • 400 field_not_file_type - Field is not a file field
  • 400 file_too_large - File exceeds max_file_size
  • 400 invalid_file_type - File MIME type not allowed

Get File (Signed URL)

Request:

http
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:

http
DELETE /v1/forms/{id}/files/{fieldKey}

Response: 200

json
{
  "data": {
    "field_key": "field_20",
    "message": "File deleted successfully"
  }
}

Business logic:

  1. Validate form is not signed (409 if signed)
  2. Remove S3 object
  3. 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:

json
{
  "key": "medical_scan",
  "label": "Medical Scan",
  "type": "file",
  "required": true,
  "max_file_size": 52428800
}

Validation on upload:

go
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:

json
{
  "error": {
    "code": "file_too_large",
    "message": "File size exceeds maximum allowed",
    "details": {
      "file_size": 15728640,
      "max_size": 10485760
    }
  }
}

MIME Type Validation

Default allowed types:

json
["image/png", "image/jpeg", "image/webp", "application/pdf"]

Override in field definition:

json
{
  "key": "profile_photo",
  "label": "Profile Photo",
  "type": "file",
  "required": false,
  "allowed_file_types": ["image/png", "image/jpeg"]
}

Validation on upload:

go
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:

json
{
  "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:

go
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:

json
{
  "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:

go
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:

ConstraintDefault
max_file_size10 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:

  1. User has access to the form (RLS policies)
  2. User requests a signed URL (via API)
  3. 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:

  1. Content-Type header from client
  2. File extension
  3. Magic bytes (file signature detection)
go
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.name for display only

Example Field Definition

json
{
  "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