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:
- Lambda functions handle API requests with OAuth2 authentication
- S3 stores all content with appropriate metadata
- CloudFront serves content with caching and security headers
- Handlebars templates render HTML interfaces
Architecture
High-Level Components
┌─────────────────┐
│ CloudFront │ ← User requests
│ Distribution │
└────────┬────────┘
│
┌────┴────┐
│ │
┌───▼────┐ ┌─▼──────────┐
│ Assets │ │ API Gateway│
│ Bucket │ │ (HTTP) │
└────────┘ └─────┬──────┘
▲ │
│ ┌─────┴──────┐
│ │ │
│ ┌───▼────┐ │
│ │ Edit │ │
│ │ Lambda │ │
│ └───┬────┘ │
│ │ │
└──────┴────────────┘
(presigned URL)
Request Flow
Upload Flow:
- User accesses edit page → Edit Lambda renders form with “Upload File” button
- User clicks “Upload File” → GET
/upload?page={page-path}→ Edit Lambda renders upload form - Edit Lambda generates presigned S3 URL → Embeds in HTML form action
- User selects file and submits → Browser POSTs directly to S3 using presigned URL
- Upload completes → User redirected back to edit page
Retrieval Flow:
- User requests
/attachments/{page-path}/{filename} - CloudFront checks cache → If miss, fetches from S3
- S3 returns file with Content-Type metadata
- CloudFront caches and serves to user
Delete Flow:
- User clicks delete on attachment → GET
/delete-attachment?page={page-path}&file={filename} - Edit Lambda renders confirmation page
- User confirms → POST
/delete-attachmentwith page and filename - 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
- Query parameter:
page(page slug) - Validates authentication via OAuth2 authorizer
- Generates presigned S3 POST URL with:
- Content-Type metadata based on allowed types
- 15-minute expiration
- Target path:
/attachments/{page-path}/
- Renders HTML form with presigned URL as action
- Form includes file input and hidden fields for S3 POST
GET /delete-attachment
- Query parameters:
page(page slug),file(filename) - Validates authentication via OAuth2 authorizer
- Renders confirmation page with attachment details
- Similar to existing delete page for wiki pages
POST /delete-attachment
- Form data:
page(page slug),file(filename) - Validates authentication via OAuth2 authorizer
- Deletes file from S3 at
/attachments/{page-path}/{filename} - Redirects back to edit page
Edit Page Enhancement:
- List attachments by querying S3 with prefix
/attachments/{page-path}/ - Display attachments with links and delete buttons
- Add “Upload File” button linking to
/upload?page={page-path}
Environment Variables:
ASSETS_BUCKET: S3 bucket name for attachmentsENVIRONMENT: dev or prodUSER_POOL_DOMAIN: Cognito domain for authUSER_POOL_CLIENT_ID: Cognito client ID
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:
.png→image/png.jpg,.jpeg→image/jpeg.svg→image/svg+xml
Validation Logic:
- Extract file extension from filename
- Check extension against allowlist
- Return validation result with specific error messages
- 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:
- Method: POST (with form fields for browser compatibility)
- Expiration: 15 minutes (900 seconds)
- Conditions: File size limit, content-type restrictions
- Path prefix:
/attachments/{page-path}/ - Special case: For page “index”, use
/attachments/root/
Form Fields:
key: Target S3 path with${filename}variableContent-Type: Starts withimage/acl:private(served via CloudFront)policy: Base64-encoded policy documentx-amz-algorithm: AWS4-HMAC-SHA256x-amz-credential: AWS credentialsx-amz-date: Request datex-amz-signature: Request signature
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:
- Use S3 ListObjectsV2 with prefix
/attachments/{page-path}/ - Extract filename, size, and lastModified from S3 objects
- Generate CloudFront URLs for each attachment
- No metadata files needed - all info from S3 object properties
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:
attachments/assets/auth/
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:
- TTL: 24 hours for attachments (CloudFront default for CACHING_OPTIMIZED)
- Methods: GET, HEAD only for attachments
- Compression: Disabled (images are already compressed)
- Security headers: Applied via existing policy
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:
key: S3 object key pattern with${filename}Content-Type: Content type restrictionpolicy: Base64-encoded policyx-amz-algorithm: Signing algorithmx-amz-credential: AWS credentialsx-amz-date: Request datex-amz-signature: Request signature
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:
- Status: 400 Bad Request
- Message: “File type not allowed. Supported types: png, jpg, jpeg, svg”
- Action: Return error to user, do not generate presigned URL
Authentication Errors
Unauthenticated Request:
- Status: 401 Unauthorized
- Message: “Authentication required to upload or delete attachments”
- Action: Return error with login URL
Missing User Context:
- Status: 401 Unauthorized
- Message: “Unable to verify user identity”
- Action: Return error, log incident
Presigned URL Generation Errors
S3 Client Error:
- Status: 500 Internal Server Error
- Message: “Failed to generate upload URL”
- Action: Log error details, return generic error to user
Invalid Path:
- Status: 400 Bad Request
- Message: “Invalid page path or filename”
- Action: Return error to user
Deletion Errors
S3 Delete Failure:
- Status: 500 Internal Server Error
- Message: “Failed to delete attachment”
- Action: Log error details, return error to user
File Not Found:
- Status: 404 Not Found
- Message: “Attachment not found”
- Action: Return error to user
Path Validation Errors
Reserved Path:
- Status: 400 Bad Request
- Message: “Page path cannot start with reserved prefixes: attachments/, assets/, auth/”
- Action: Return error to user, display reserved prefixes
Listing Errors
S3 List Failure:
- Status: 500 Internal Server Error
- Message: “Failed to load attachments”
- Action: Log error, return empty list or error to user
Testing Strategy
Dual Testing Approach
This feature requires both unit tests and property-based tests for comprehensive coverage:
Unit Tests focus on:
- Specific examples of valid presigned URL requests (one png, one jpg, one svg)
- Edge cases (empty filename, special characters in filename, index page path)
- Error conditions (unauthenticated request, invalid extension)
- Integration points (Lambda event parsing, S3 client calls, response formatting)
Property-Based Tests focus on:
- Universal properties across all inputs (file validation, path generation, Content-Type mapping)
- Comprehensive input coverage through randomization (various filenames, page slugs)
- Invariants that must hold (presigned URLs always have correct path and Content-Type)
Property-Based Testing Configuration
Library: fast-check (TypeScript/JavaScript property-based testing library)
Configuration:
- Minimum 100 iterations per property test
- Each test tagged with feature name and property number
- Tag format:
// Feature: attachment-support, Property {N}: {property text}
Test Organization:
- Property tests in
test/file-lambda.property.test.ts - Unit tests in
test/file-lambda.test.ts - Integration tests in
test/attachment-integration.test.ts
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
- File validation: 100% coverage of validation logic
- Presigned URL generation: 90%+ coverage of request handling
- File deletion: 100% coverage of S3 operations
- Path validator: 100% coverage of reserved path checks
- Edit template: Visual testing for UI components
Integration Testing
End-to-End Upload Flow:
- Authenticate user
- Request presigned URL for valid file
- Verify presigned URL returned with correct parameters
- Upload file to S3 using presigned URL (simulated)
- Verify file in S3 with correct Content-Type
- Request URL via CloudFront (if available)
- Verify file served with correct Content-Type
End-to-End Delete Flow:
- Upload file to S3 (setup)
- Delete file via API
- Verify file removed from S3
- Verify file no longer in attachment list
Reserved Path Validation:
- Attempt to create page with “attachments/test”
- Verify rejection with error message
- Repeat for “assets/” and “auth/” prefixes