Skip to content

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