Evaly uses Cloudflare R2 for secure, scalable file storage. The system supports images, audio, and video files for rich question content with automatic CDN delivery and size validation.
Supported File Types
Images
Allowed formats: JPG, JPEG, PNG, GIF, WebP
Maximum size: 10 MB
Use cases:
Diagrams and charts in math/science questions
Visual identification questions
Reference images for essay prompts
Audio
Allowed formats: MP3, WAV, OGG, M4A, WebM
Maximum size: 50 MB
Use cases:
Language listening comprehension tests
Music theory questions
Pronunciation examples
Video
Allowed formats: MP4, WebM, MOV
Maximum size: 100 MB
Use cases:
Video-based question prompts
Instructional content
Scenario-based assessments
All formats are optimized for Cloudflare Image Optimization and streaming delivery.
Upload Flow
Evaly uses a secure two-step upload process:
Generate Upload URL
Request a pre-signed upload URL from the server: const { uploadUrl , key , publicUrl } = await generateMediaUploadUrl ({
filename: "diagram.png" ,
mediaType: "image" ,
fileSize: 2048576 // 2 MB in bytes
});
Server-side validation:
User authentication
Organization membership
File extension validation
File size validation
Unique key generation
Upload to R2
Upload the file directly to Cloudflare R2 using the pre-signed URL: const response = await fetch ( uploadUrl , {
method: "PUT" ,
body: file ,
headers: {
"Content-Type" : file . type
}
});
if ( ! response . ok ) {
throw new Error ( "Upload failed" );
}
Use Public URL
Store the public URL in your question data: // Public URL format: {R2_CDN_URL}/{key}
// Example: https://cdn.evaly.io/editor-media/org_123/image/abc-def.png
await updateQuestion ({
questionId ,
content: `<img src=" ${ publicUrl } " alt="Diagram" />`
});
File Organization
Files are organized by organization and media type:
editor-media/
{organizationId}/
image/
{uuid}.png
{uuid}.jpg
audio/
{uuid}.mp3
{uuid}.wav
video/
{uuid}.mp4
{uuid}.webm
Benefits:
Clear namespace separation per organization
Easy to identify media type
Collision-free with UUID filenames
Simple to implement bulk deletion per organization
Storage Configuration
Environment Variables
Configure R2 in your Convex environment:
R2_ACCESS_KEY_ID = your_access_key
R2_SECRET_ACCESS_KEY = your_secret_key
R2_BUCKET = evaly-media
R2_ENDPOINT = https://account.r2.cloudflarestorage.com
R2_CDN_URL = https://cdn.evaly.io
R2_TOKEN = your_r2_token
Security: Never expose these credentials in client-side code. All R2 operations must go through Convex server functions.
R2 Client Initialization
// convex/common/storage.ts
import { R2 } from "@convex-dev/r2" ;
import { components } from "../_generated/api" ;
export const r2 = new R2 ( components . r2 );
export const { generateUploadUrl , syncMetadata } = r2 . clientApi ({
checkUpload : async ( ctx ) => {
const userId = await getAuthUserId ( ctx );
if ( ! userId ) {
throw new ConvexError ({ message: "Not authenticated" });
}
}
});
Upload Validation
File Extension Validation
const ALLOWED_IMAGE_EXTENSIONS = [ "jpg" , "jpeg" , "png" , "gif" , "webp" ];
const ALLOWED_AUDIO_EXTENSIONS = [ "mp3" , "wav" , "ogg" , "m4a" , "webm" ];
const ALLOWED_VIDEO_EXTENSIONS = [ "mp4" , "webm" , "mov" ];
const extension = filename . split ( "." ). pop ()?. toLowerCase () || "" ;
if ( ! allowedExtensions . includes ( extension )) {
throw new ConvexError ({
message: `Invalid file type. Allowed ${ mediaType } formats: ${ allowedExtensions . join ( ", " ) } `
});
}
File Size Validation
const MAX_IMAGE_SIZE = 10 * 1024 * 1024 ; // 10 MB
const MAX_AUDIO_SIZE = 50 * 1024 * 1024 ; // 50 MB
const MAX_VIDEO_SIZE = 100 * 1024 * 1024 ; // 100 MB
if ( fileSize > maxSize ) {
const maxSizeMB = maxSize / ( 1024 * 1024 );
throw new ConvexError ({
message: `File too large. Maximum ${ mediaType } size is ${ maxSizeMB } MB`
});
}
Validation happens before upload URL generation, preventing unnecessary upload bandwidth.
URL Conversion Utilities
Key to Public URL
Convert R2 key to CDN URL:
import { keyToPublicUrl } from "@/convex/common/storage" ;
const key = "editor-media/org_123/image/abc-def.png" ;
const publicUrl = keyToPublicUrl ( key );
// Returns: "https://cdn.evaly.io/editor-media/org_123/image/abc-def.png"
Features:
Handles already-converted URLs (idempotent)
Uses environment variable for CDN URL
Strips trailing slashes automatically
Public URL to Key
Extract R2 key from public URL (for deletion):
import { publicUrlToKey } from "@/convex/common/storage" ;
const url = "https://cdn.evaly.io/editor-media/org_123/image/abc-def.png" ;
const key = publicUrlToKey ( url );
// Returns: "editor-media/org_123/image/abc-def.png"
Use cases:
Deleting files when questions are removed
Migrating to new storage backend
Cleanup operations
File Deletion
Delete files from R2 storage:
import { r2 , publicUrlToKey } from "@/convex/common/storage" ;
// From public URL
const key = publicUrlToKey ( publicUrl );
await r2 . deleteObject ( key );
// Direct key
await r2 . deleteObject ( "editor-media/org_123/image/abc-def.png" );
Example: Delete Question Images
export const deleteQuestion = mutation ({
args: { questionId: v . id ( "question" ) },
handler : async ( ctx , args ) => {
const question = await ctx . db . get ( args . questionId );
if ( ! question ) return ;
// Extract image URLs from question content
const imageUrls = extractImageUrls ( question . content );
// Delete all associated files
for ( const url of imageUrls ) {
const key = publicUrlToKey ( url );
try {
await r2 . deleteObject ( key );
} catch ( error ) {
console . warn ( `Failed to delete file ${ key } :` , error );
}
}
// Delete the question
await ctx . db . patch ( args . questionId , {
deletedAt: Date . now ()
});
}
});
CDN Integration
Cloudflare R2 is integrated with Cloudflare CDN for fast global delivery:
Benefits:
Automatic caching at edge locations
Image optimization and resizing
Reduced bandwidth costs
Faster load times for participants
CDN URL Structure:
https://cdn.evaly.io/{key}
Configure your R2 bucket with a custom domain in the Cloudflare dashboard to use the CDN.
Frontend Integration
React Upload Component Example
import { useMutation } from "convex/react" ;
import { api } from "@/convex/_generated/api" ;
function ImageUploader () {
const generateUrl = useMutation ( api . organizer . editorMedia . generateMediaUploadUrl );
const handleUpload = async ( file : File ) => {
// Step 1: Generate upload URL
const { uploadUrl , publicUrl } = await generateUrl ({
filename: file . name ,
mediaType: "image" ,
fileSize: file . size
});
// Step 2: Upload to R2
const response = await fetch ( uploadUrl , {
method: "PUT" ,
body: file
});
if ( ! response . ok ) {
throw new Error ( "Upload failed" );
}
// Step 3: Use publicUrl in your content
return publicUrl ;
};
return (
< input
type = "file"
accept = "image/*"
onChange = {(e) => {
const file = e . target . files ?.[ 0 ];
if ( file ) handleUpload ( file );
}}
/>
);
}
TipTap Editor Integration
import { useEditor } from "@tiptap/react" ;
import Image from "@tiptap/extension-image" ;
const editor = useEditor ({
extensions: [
Image . configure ({
allowBase64: false ,
HTMLAttributes: {
class: "max-w-full h-auto"
}
})
],
editorProps: {
handleDrop : async ( view , event , slice , moved ) => {
const file = event . dataTransfer ?. files [ 0 ];
if ( file ?. type . startsWith ( "image/" )) {
event . preventDefault ();
const publicUrl = await handleUpload ( file );
const { schema } = view . state ;
const node = schema . nodes . image . create ({ src: publicUrl });
const transaction = view . state . tr . replaceSelectionWith ( node );
view . dispatch ( transaction );
return true ;
}
return false ;
}
}
});
Error Handling
Common Upload Errors
{
message : "Not authenticated"
}
Solution: Ensure user is logged in before attempting upload.
{
message : "Invalid file type. Allowed image formats: jpg, jpeg, png, gif, webp"
}
Solution: Check file extension before calling generateMediaUploadUrl.
{
message : "File too large. Maximum image size is 10MB"
}
Solution: Compress or resize file before upload.
{
message : "Organization not found"
}
Solution: Ensure user has selected an organization.
Best Practices
Always validate file size client-side before requesting upload URL to save bandwidth
Show upload progress to users for better UX (use XMLHttpRequest instead of fetch)
Compress images before upload to reduce storage costs
Delete orphaned files when questions are deleted to prevent storage bloat
Use lazy loading for images in question lists to improve performance
Handle upload failures gracefully with retry logic
Cache CDN URLs in the browser for faster repeat access
Use appropriate formats : WebP for images, MP4 for video, MP3 for audio
Storage Limits by Plan
Storage quotas (if implemented):
Free: 1 GB total storage
Pro: 50 GB total storage
Enterprise: Unlimited storage
Contact your plan administrator for current limits.
Migrating Existing Files
If migrating from another storage provider:
Export File List
Get all file URLs from your current provider.
Download Files
Download files to local storage or temporary S3 bucket.
Upload to R2
Use the standard upload flow to upload files to R2: for ( const file of files ) {
const { uploadUrl , publicUrl } = await generateMediaUploadUrl ({
filename: file . name ,
mediaType: detectMediaType ( file . name ),
fileSize: file . size
});
await uploadToR2 ( uploadUrl , file . data );
// Update database with new URL
await updateQuestionImageUrl ( file . questionId , publicUrl );
}
Verify Migration
Check that all questions display images correctly.
Clean Up
Delete files from old storage provider.