File Upload Design

Design Document: Attachment Support

Overview

This design adds file attachment support to the S3 wiki system, enabling authenticated users to upload images (png, jpg, jpeg, svg) and reference them in wiki pages using markdown syntax. The implementation extends the existing Lambda-based architecture with a new upload endpoint, stores attachments in the existing assets S3 bucket under an /attachments/ prefix, and configures CloudFront to serve attachments with proper caching and content types.

The design follows the existing patterns in the wiki system:

Architecture

High-Level Components

┌─────────────────┐
│   CloudFront    │ ← User requests
│  Distribution   │
└────────┬────────┘
         │
    ┌────┴────┐
    │         │
┌───▼────┐ ┌─▼──────────┐
│ Assets │ │ API Gateway│
│ Bucket │ │   (HTTP)   │
└────────┘ └─────┬──────┘
    ▲            │
    │      ┌─────┴──────┐
    │      │            │
    │  ┌───▼────┐       │
    │  │  Edit  │       │
    │  │ Lambda │       │
    │  └───┬────┘       │
    │      │            │
    └──────┴────────────┘
      (presigned URL)

Request Flow

Upload Flow:

  1. User accesses edit page → Edit Lambda renders form with “Upload File” button
  2. User clicks “Upload File” → GET /upload?page={page-path} → Edit Lambda renders upload form
  3. Edit Lambda generates presigned S3 URL → Embeds in HTML form action
  4. User selects file and submits → Browser POSTs directly to S3 using presigned URL
  5. Upload completes → User redirected back to edit page

Retrieval Flow:

  1. User requests /attachments/{page-path}/{filename}
  2. CloudFront checks cache → If miss, fetches from S3
  3. S3 returns file with Content-Type metadata
  4. CloudFront caches and serves to user

Delete Flow:

  1. User clicks delete on attachment → GET /delete-attachment?page={page-path}&file={filename}
  2. Edit Lambda renders confirmation page
  3. User confirms → POST /delete-attachment with page and filename
  4. Edit Lambda deletes from S3 → Redirects back to edit page

Components and Interfaces

1. Edit Lambda Enhancement

Purpose: Extend existing edit Lambda to handle upload and delete attachment flows.

New Endpoints:

GET /upload

GET /delete-attachment

POST /delete-attachment

Edit Page Enhancement:

Environment Variables:

2. File Validator Module

Purpose: Validate file extensions for presigned URL generation.

Interface:

interface FileValidator {
  validateFileExtension(filename: string): ValidationResult;
  getContentType(filename: string): string;
}

interface ValidationResult {
  isValid: boolean;
  errors: string[];
}

Allowed File Types:

Validation Logic:

  1. Extract file extension from filename
  2. Check extension against allowlist
  3. Return validation result with specific error messages
  4. Map extension to Content-Type for presigned URL

3. Presigned URL Generator

Purpose: Generate secure presigned S3 URLs for direct browser uploads.

Interface:

interface PresignedUrlGenerator {
  generatePostUrl(
    pageSlug: string
  ): Promise<PresignedPostResponse>;
}

interface PresignedPostResponse {
  url: string;
  fields: Record<string, string>; // Hidden form fields
  expiresIn: number; // seconds
}

S3 Configuration:

Form Fields:

4. Attachment Listing

Purpose: List attachments for a page from S3.

Interface:

interface AttachmentLister {
  listAttachments(pageSlug: string): Promise<AttachmentInfo[]>;
}

interface AttachmentInfo {
  filename: string;
  url: string;
  size: number;
  lastModified: string;
}

Implementation:

5. Reserved Path Validator

Purpose: Prevent users from creating pages with reserved path prefixes.

Interface:

interface PathValidator {
  isReservedPath(slug: string): boolean;
  getReservedPrefixes(): string[];
}

Reserved Prefixes:

Integration: Add validation to Edit Lambda before saving pages.

6. CloudFront Configuration

Purpose: Serve attachments via CDN with proper caching and content types.

New Behavior:

"/attachments/*": {
  origin: S3BucketOrigin.withOriginAccessControl(assetsBucket, {
    originAccessControl: oac,
  }),
  viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
  cachedMethods: CachedMethods.CACHE_GET_HEAD,
  compress: false, // Don't compress images
  cachePolicy: CachePolicy.CACHING_OPTIMIZED, // 24 hour TTL
  responseHeadersPolicy: securityHeadersPolicy,
}

Additional Behaviors for New Endpoints:

"/upload": {
  origin: apiGatewayOrigin,
  viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  allowedMethods: AllowedMethods.ALLOW_ALL,
  cachePolicy: CachePolicy.CACHING_DISABLED,
  originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
},
"/delete-attachment": {
  origin: apiGatewayOrigin,
  viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  allowedMethods: AllowedMethods.ALLOW_ALL,
  cachePolicy: CachePolicy.CACHING_DISABLED,
  originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
}

Cache Behavior:

Data Models

PresignedPostResponse (For Upload Form)

interface PresignedPostResponse {
  url: string;                      // S3 bucket URL
  fields: Record<string, string>;   // Hidden form fields
  expiresIn: number;                // Expiration in seconds (900)
}

Form Fields:

AttachmentInfo (For Edit Page Display)

interface AttachmentInfo {
  filename: string;          // File name
  url: string;               // CloudFront URL to file
  size: number;              // Size in bytes
  lastModified: string;      // ISO 8601 timestamp from S3
}

Source: S3 ListObjectsV2 response - no separate metadata files needed

Correctness Properties

A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.

Property 1: File Extension Validation

For any filename, the File_Validator should accept files with extensions png, jpg, jpeg, or svg, and reject all other extensions with a clear error message.

Validates: Requirements 2.1, 2.2, 2.3

Property 2: Presigned URL Generation

For any valid page slug with authentication, the Edit Lambda should generate a presigned S3 POST URL with the correct path prefix /attachments/{page-path}/ and appropriate form fields.

Validates: Requirements 1.3, 1.4, 5.1, 5.4, 10.3

Property 3: Content-Type Mapping

For any allowed file extension, the presigned URL generator should set the correct Content-Type: image/png for .png, image/jpeg for .jpg/.jpeg, and image/svg+xml for .svg.

Validates: Requirements 8.4

Property 4: Storage Path Pattern

For any uploaded attachment, the file should be stored at /attachments/{page-path}/{filename} where page-path is “root” if the page slug is “index”.

Validates: Requirements 4.1

Property 5: File Deletion

For any authenticated delete request, the Edit Lambda should remove the attachment file from S3 at the specified path.

Validates: Requirements 7.3

Property 6: Attachment Listing

For any page slug, listing attachments should return all files under /attachments/{page-path}/ with filename, size, lastModified, and URL.

Validates: Requirements 6.2, 6.3

Property 7: Reserved Path Validation

For any page slug starting with “attachments/”, “assets/”, or “auth/”, the path validator should reject the page creation/edit request and return a clear error message explaining the path is reserved.

Validates: Requirements 11.1, 11.2, 11.3, 11.4

Error Handling

File Validation Errors

Invalid File Extension:

Authentication Errors

Unauthenticated Request:

Missing User Context:

Presigned URL Generation Errors

S3 Client Error:

Invalid Path:

Deletion Errors

S3 Delete Failure:

File Not Found:

Path Validation Errors

Reserved Path:

Listing Errors

S3 List Failure:

Testing Strategy

Dual Testing Approach

This feature requires both unit tests and property-based tests for comprehensive coverage:

Unit Tests focus on:

Property-Based Tests focus on:

Property-Based Testing Configuration

Library: fast-check (TypeScript/JavaScript property-based testing library)

Configuration:

Test Organization:

Example Property Test Structure

import fc from 'fast-check';

describe('File Lambda Properties', () => {
  // Feature: attachment-support, Property 1: File Extension Validation
  it('should validate file extensions correctly for all inputs', () => {
    fc.assert(
      fc.property(
        fc.string(),
        (filename) => {
          const validator = new FileValidator();
          const result = validator.validateFileExtension(filename);

          const allowedExtensi ['.png', '.jpg', '.jpeg', '.svg'];
          const hasAllowedExt = allowedExtensions.some(ext =>
            filename.toLowerCase().endsWith(ext)
          );

          return result.isValid === hasAllowedExt;
        }
      ),
      { numRuns: 100 }
    );
  });

  // Feature: attachment-support, Property 3: Content-Type Mapping
  it('should map file extensions to correct Content-Type', () => {
    fc.assert(
      fc.property(
        fc.constantFrom('.png', '.jpg', '.jpeg', '.svg'),
        fc.string(),
        (ext, basename) => {
          const filename = basename + ext;
          const validator = new FileValidator();
          const c validator.getContentType(filename);

          const expectedTypes = {
            '.png': 'image/png',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.svg': 'image/svg+xml',
          };

          return c== expectedTypes[ext];
        }
      ),
      { numRuns: 100 }
    );
  });
});

Test Coverage Goals

Integration Testing

End-to-End Upload Flow:

  1. Authenticate user
  2. Request presigned URL for valid file
  3. Verify presigned URL returned with correct parameters
  4. Upload file to S3 using presigned URL (simulated)
  5. Verify file in S3 with correct Content-Type
  6. Request URL via CloudFront (if available)
  7. Verify file served with correct Content-Type

End-to-End Delete Flow:

  1. Upload file to S3 (setup)
  2. Delete file via API
  3. Verify file removed from S3
  4. Verify file no longer in attachment list

Reserved Path Validation:

  1. Attempt to create page with “attachments/test”
  2. Verify rejection with error message
  3. Repeat for “assets/” and “auth/” prefixes
Edit Page Create Sub Page Delete Page