Image Cropper And Uploader

  • state controller
  • image uploads
  • aws
  • db
  • s3
  • cloudfront
  • rekognition
  • rate limiting

28 min read 6151 words

Sign in to use the demo or view

Let's allow our users to upload an image. We'll use an avatar as an example. This simple feature will touch a lot of topics:

  • Infrastructure

  • Client Features

    • Upload state machine controller with Svelte
      $state()
    • Crop, file select, upload, and delete capabilities
    • Graceful interruption and cancellation handling
    • User friendly errors
    • Size and file type guards
    • File upload progress bar
    • Images loaded from CDN
  • Server Guards

    • User authorization
    • Rate limits
    • Explicit content deletion
    • Image storage access authorization
    • Size and file type limits
    • Zero trust client relationship

Of course, there are many alternatives to each infrastructure choice. For example, ImageKit could be used as the image storage and CDN, an in-memory cache such as ttlcache could be used as the kv store, Google Cloud Vision could be used for content moderation, and so on. To keep it simple, we'll use AWS for everything except the database and kv store, which will be hosted directly on the server.

Bring Your Own Database
This article focuses on the SvelteKit logic and integrating the AWS services. Setting up each AWS service will be detailed, but it's assumed you have a database and kv store.
If not, you can use the pnpm dev:up script in this repo's package.json to create the necessary database and kv docker containers.

Upload Flow Options

The basic flow is simple. Get a file input from the user, upload it to storage, and save a link in a database.

However, there are multiple possible implementation variants to our flow. There are also security features or UI enhancements we'll want to implement. First, let's evaluate a few different ways our basic flow could be implemented so we can choose one.

0.1 Server Heavy

The Flow

  • The client uploads a file to the server.
  • The server checks user authorization, rate limits the request, checks the file size, and checks the content moderation with Rekognition.
  • The server uploads the file to an S3 bucket.
  • The server saves the file url into the database.
  • In order to show the client the progress that has been made, the server pushes server-sent events with a connection made with ReadableStream and EventSource.

Pros

  • Simple to make safe. The server has complete control.

Cons

  • Slow. The file has to make two trips (from client to server to AWS).
  • Memory inefficient. The server has to hold the file in memory during the entire pipeline.

0.2 With Webhook

The Flow

  • The server checks user authorization, rate limits the request, generates an upload token with a predetermined size, and sends the presigned url to the client.
  • The client uses the token to upload the file to an S3 bucket.
  • An AWS event notification notifies the server that a file has been created.
  • The server validates the image with Rekognition (and deletes it if it fails validation) before saving the url to the database.

Pros

  • Memory efficient. The file is never on the server.
  • Simple to make safe. We rely on AWS, not the client to notify the server between steps.

Cons

  • Variable speed. Event notifications can take over a minute.
  • Higher complexity. Involves setting up an event notification with a lambda that POSTs the event notification data to our server.
  • Harder to show the client update progress. If the updates were sent via a long lived connection (server-sent events or websockets) with an app scaled to multiple instances, the client request and the AWS notification might hit different instances. The client would have to poll the server or an adapter would be necessary to broadcast the event to the correct app instance.

0.3 Guarded Client Control

The Flow

  • The server checks user authorization, rate limits the request, generates an upload token with a predetermined size, stores the presigned url, creates a cleanup job, and sends the presigned url to the client.
  • The client uses the token to upload the file to an S3 bucket.
  • The client notifies the server that the image has been uploaded. The server confirms the image has truly been uploaded to the presigned url location, validates the image with Rekognition, removes the cleanup job, saves the url to the database, and returns the response to the client.
  • If the client uploads to the url but doesn't notify us (either due to malicious intent or via a canceled/interrupted request), the cleanup job will delete it.

Pros

  • Fast. The file goes directly from the client to AWS.
  • Memory efficient. The file is never on the server.
  • Easy to show progress. We can use three distinct http requests and the upload can use an XMLHttpRequest with req.upload.addEventListener('progress', cb).

Cons

  • Moderate complexity to make safe. We have to store knowledge of the presigned url request and also add a cleanup job to thwart malicious actors.

Choice

This article implements the guarded client control method. It provides the best client experience (fastest, most visible progress), incurs minimal strain on the server, avoids extra AWS complexity, and isn't too hard to protect.

If we try to view the server, client, and services all at once, we end up with something a hard to reason with (rate limiting and presigned url storage not shown):

Upload flow

By focusing on client, AWS, and server separately, the flow become very easy to reason about. We'll begin with the client. Separating the logic from the UI and breaking it down into finite states will make it trivial to understand.

State Controller

As usual, we'll start by defining some Types and Interfaces that will simplify the implementation.

Crop Types

One of our requirements is that the user should be able to crop their image. We'll do a CSS crop so we don't have to worry about modifying the image. This website uses @samplekit/svelte-crop-window which is a loving rune/builder based rewrite of sabine/svelte-crop-window. This package will handle the cropping logic for us, and we'll simply store that data alongside the url.

AvatarEditor.svelte
import { z } from 'zod';

export const cropSchema = z
	.object({
		position: z.object({ x: z.number(), y: z.number() }),
		aspect: z.number(),
		rotation: z.number(),
		scale: z.number(),
	})
	.default({ position: { x: 0, y: 0 }, aspect: 1, rotation: 0, scale: 1 });

export type CropValue = z.infer<typeof cropSchema>;

export const croppedImgSchema = z.object({
	url: z.string().url(),
	crop: cropSchema,
});

export type CroppedImg = z.infer<typeof croppedImgSchema>;

States

We need to break the flow into separate states. That could be done in many ways, but I'm going to break them along the following lines.

State Change BreakpointReasoning
There's a decision branchAllow for easily composable pipelines
An async function is being awaitedAllow cancellation logic to be added to the state so a cancel function can handle cancels that occurred during await promise
Something must be shown in the UIAllow getting user input, showing spinners, updating progress bars, etc.

Static

We need some static states:

cropImgUploadController Static States
export type Idle = { state: 'idle' }; // entry state
export type Err = {
	state: 'error';
	img: { url: string | null; crop: CropValue | null };
	errorMsgs: [string, string] | [string, null]; // [title, body]
};
export type Completed = { state: 'completed'; savedImg: CroppedImg | null };

Preexisting

If the user has an avatar already, we'll want to allow them to crop it, save the updated crop value, and delete it.

cropImgUploadController Preexisting States
export type CroppingPreexisting = {
	state: 'cropping_preexisting'; // entry state
	url: string;
	crop: CropValue;
	/**
	 * Update a preexisting image's crop value.
	 * States: 'cropping_preexisting' -> 'db_updating_preexisting' -> 'completed'
	 */
	saveCropValToDb({ crop }: { crop: CropValue }): Promise<Idle | Err | Completed>;
	/**
	 * Delete a preexisting image.
	 * States: 'cropping_preexisting' -> 'deleting_preexisting' -> 'completed'
	 */
	deleteImg: () => Promise<Idle | Err | Completed>;
};
export type DbUpdatingPreexisting = {
	state: 'db_updating_preexisting';
	url: string;
	crop: CropValue;
	updateDbPromise: Promise<Result<{ savedImg: CroppedImg | null }>>;
};
export type DeletingPreexisting = {
	state: 'deleting_preexisting';
	url: string;
	crop: CropValue;
	deletePreexistingImgPromise: Promise<Result<Result.Success>>;
};

New

If the user doesn't have an image, or if they are selecting a new image, we'll need states to facilitate selecting the file, loading it into memory, cropping it, getting the presigned url, sending it to AWS, and saving the url to the DB.

cropImgUploadController New Image States
import type { Tweened } from 'svelte/motion';

export type FileSelecting = {
	state: 'file_selecting'; // entry state
	/**
	 * Convert a file into an in memory uri, guarding max file size.
	 * States: 'file_selecting' -> 'uri_loading' -> 'uri_loaded'
	 */
	loadFiles: (a: { files: File[]; MAX_UPLOAD_SIZE: number }) => Promise<Idle | Err | UriLoaded>;
};
export type UriLoading = {
	state: 'uri_loading';
	file: File;
	uriPromise: Promise<{ uri: string; error?: never } | { uri?: never; error: Error | DOMException }>;
};
export type UriLoaded = {
	state: 'uri_loaded';
	file: File;
	uri: string;
	/** States: 'uri_loaded' -> 'cropped' */
	skipCrop: (a?: { crop: CropValue }) => Cropped;
	/** States: 'uri_loaded' -> 'cropping' */
	startCrop: () => Cropping;
};
export type Cropping = {
	state: 'cropping';
	file: File;
	uri: string;
	/** States: 'cropping' -> 'cropped' */
	loadCropValue: ({ crop }: { crop: CropValue }) => Cropped;
};
export type Cropped = {
	state: 'cropped';
	file: File;
	uri: string;
	crop: CropValue;
	/** States: 'cropped' -> 'upload_url_fetching' -> 'image_storage_uploading' -> 'db_saving' -> 'completed' */
	uploadCropped(): Promise<Idle | Err | Completed>;
};
export type UploadUrlFetching = {
	state: 'upload_url_fetching';
	file: File;
	uri: string;
	crop: CropValue;
	uploadProgress: Tweened<number>;
	getUploadArgsPromise: Promise<Result<{ bucketUrl: string; formDataFields: Record<string, string> }>>;
};
export type ImageStorageUploading = {
	state: 'image_storage_uploading';
	uri: string;
	crop: CropValue;
	imageUploadPromise: Promise<Result<{ status: number }>>;
	uploadProgress: Tweened<number>;
};
export type DbSaving = {
	state: 'db_saving';
	uri: string;
	crop: CropValue;
	saveToDbPromise: Promise<Result<{ savedImg: CroppedImg | null }>>;
	uploadProgress: Tweened<number>;
};

Union

Putting it all together, the controller has the following states:

cropImgUploadController Union of States
export type CropControllerState =
	| Idle
	| Err
	| Completed
	| CroppingPreexisting
	| DbUpdatingPreexisting
	| DeletingPreexisting
	| FileSelecting
	| UriLoading
	| UriLoaded
	| Cropping
	| Cropped
	| UploadUrlFetching
	| ImageStorageUploading
	| DbSaving;

export type StateName = CropControllerState['state'];

Callback Types

Notice that each of the three state groups has a logical entry point. We start with the Idle state. If the user already has an avatar, we can then transition to the CroppingPreexisting state. Otherwise, we can use the FileSelecting state.

Some states have methods which can be used to transition to other states. Others don't because they are simply transitional UI states in the pipeline that don't need user interaction.

In order to start implementing those states, we will need to accept some callbacks in our constructor.

import type { CroppedImg, CropValue } from '$lib/image/common';
import type { Result } from '$lib/utils/common';

type GetUploadArgs = () => Promise<Result<{ bucketUrl: string; formDataFields: Record<string, string> }>>;
type Upload = (a: {
	bucketUrl: string;
	formData: FormData;
	uploadProgress: { tweened: Tweened<number>; scale: number };
}) => { promise: Promise<Result<{ status: number }>>; abort: () => void };
type SaveToDb = (a: { crop: CropValue }) => Promise<Result<{ savedImg: CroppedImg | null }>>;
type DeletePreexistingImg = () => Promise<Result<Result.Success>>;
type SaveCropToDb = (a: { crop: CropValue }) => Promise<Result<{ savedImg: CroppedImg | null }>>;

Implementation

Enough with the types – let's implement! We'll obviously need to store our state and callbacks. Additionally, if the user exits in the middle of an async function, we'll want to be able to gracefully shut down, so let's also add a cleanup method for that. Finally, we'll derive isIdle for convenience.

Idle Entrypoint

Our trivial idle entrypoint is the default state of the controller.

cropImgUploadController Idle Entrypoint
export class CropImgUploadController {
	#state: CropControllerState = $state.raw({ state: 'idle' });
	#isIdle: boolean = $derived(this.#state.state === 'idle');
	#cleanup: null | (() => void) = null;

	#getUploadArgs: GetUploadArgs;
	#upload: Upload;
	#saveToDb: SaveToDb;
	#delImg: DeletePreexistingImg;
	#saveCropToDb: SaveCropToDb;

	get value() {
		return this.#state;
	}

	/** Clean up and move to 'idle' */
	toIdle = async (): Promise<Idle> => {
		this.#cleanup?.();
		return (this.#state = { state: 'idle' });
	};

	constructor(a: {
		getUploadArgs: GetUploadArgs;
		upload: Upload;
		saveToDb: SaveToDb;
		delImg: DeletePreexistingImg;
		saveCropToDb: SaveCropToDb;
	}) {
		this.#getUploadArgs = a.getUploadArgs;
		this.#upload = a.upload;
		this.#saveToDb = a.saveToDb;
		this.#delImg = a.delImg;
		this.#saveCropToDb = a.saveCropToDb;

		$effect(() => this.toIdle);
	}
}

Preexisting Entrypoint

Moving on to our second entrypoint – CroppingPreexisting:

cropImgUploadController Preexisting Entrypoint
export class CropImgUploadController {
...
	/** Clean up and move to 'cropping_preexisting' */
	toCropPreexisting = ({ crop, url }: { crop: CropValue; url: string }) => {
		this.#cleanup?.();
		this.#state = {
			state: 'cropping_preexisting',
			crop,
			url,
			saveCropValToDb: ({ crop }) => this.#saveCropValToDb({ crop, url }),
			deleteImg: () => this.#deleteImg({ crop, url }),
		};
	};

	#saveCropValToDb = async ({ crop, url }: { crop: CropValue; url: string }): Promise<Idle | Err | Completed> => {
		const updateDbPromise = this.#saveCropToDb({ crop });
		this.#state = { state: 'db_updating_preexisting', updateDbPromise, crop, url };
		const { data, error } = await updateDbPromise;

		if (this.#isIdle) return { state: 'idle' };
		else if (error) return (this.#state = { state: 'error', img: { url, crop }, errorMsgs: [error.message, null] });
		else return (this.#state = { state: 'completed', savedImg: data.savedImg });
	};

	#deleteImg = async ({ crop, url }: { crop: CropValue; url: string }): Promise<Idle | Err | Completed> => {
		const deletePreexistingImgPromise = this.#delImg();
		this.#state = { state: 'deleting_preexisting', deletePreexistingImgPromise, crop, url };
		const { error } = await deletePreexistingImgPromise;

		if (this.#isIdle) return { state: 'idle' };
		else if (error) return (this.#state = { state: 'error', img: { url, crop }, errorMsgs: [error.message, null] });
		else return (this.#state = { state: 'completed', savedImg: null });
	};
}

New Entrypoint

We only have one more entry point to implement.

cropImgUploadController New Entrypoint Part 1
export class CropImgUploadController {
	...
	/** Clean up and move to 'file_selecting' */
	toFileSelect = () => {
		this.#cleanup?.();
		this.#state = { state: 'file_selecting', loadFiles: this.#loadFiles };
	};
}

In #loadFiles we load the file, turn it into a data uri, and transition to uri_loaded. That state has two transition methods to either start cropping or simply use the default crop value.

import { defaultCropValue, fileToDataUrl, humanReadableFileSize } from '$lib/image/client';

export class CropImgUploadController {
	...

	#loadFiles = async ({
		files,
		MAX_UPLOAD_SIZE,
	}: {
		files: File[];
		MAX_UPLOAD_SIZE: number;
	}): Promise<Idle | Err | UriLoaded> => {
		const file = files[0];

		if (!file) {
			return (this.#state = { state: 'error', img: { url: null, crop: null }, errorMsgs: ['No file selected', null] });
		}

		if (file.size > MAX_UPLOAD_SIZE) {
			return (this.#state = {
				state: 'error',
				img: { url: null, crop: null },
				errorMsgs: [
					`File size (${humanReadableFileSize(file.size)}) must be less than ${humanReadableFileSize(MAX_UPLOAD_SIZE)}`,
					null,
				],
			});
		}

		const uriPromise = fileToDataUrl(file);
		this.#state = { state: 'uri_loading', file, uriPromise };

		const { uri } = await uriPromise;
		if (this.#isIdle) return { state: 'idle' };
		if (!uri) {
			return (this.#state = {
				state: 'error',
				img: { url: null, crop: null },
				errorMsgs: ['Error reading file', null],
			});
		}
		return (this.#state = {
			state: 'uri_loaded',
			file,
			uri,
			skipCrop: ({ crop } = { crop: defaultCropValue }) => this.#toCropped({ crop, file, uri }),
			startCrop: () =>
				(this.#state = {
					state: 'cropping',
					file,
					uri,
					loadCropValue: ({ crop }) => this.#toCropped({ crop, file, uri }),
				}),
		});
	};

	#toCropped = ({ crop, file, uri }: { crop: CropValue; file: File; uri: string }): Cropped => {
		return (this.#state = {
			state: 'cropped',
			crop,
			file,
			uri,
			uploadCropped: async () => this.#uploadPipeline({ crop, file, uri }),
		});
	};
}

Finally we are at our last method. It's also the largest. In #uploadPipeline we will get the upload url, upload the file, and save it to the DB.

cropImgUploadController New Entrypoint Part 3
import { circOut } from 'svelte/easing';
import { tweened, type Tweened } from 'svelte/motion';

export class CropImgUploadController {
	...
	#uploadPipeline = async ({
		file,
		uri,
		crop,
	}: {
		file: File;
		uri: string;
		crop: CropValue;
	}): Promise<Idle | Err | Completed> => {
		/** Get the upload url from our server (progress 3-10%) */
		const uploadProgress = tweened(0, { easing: circOut });
		const getUploadArgsPromise = this.#getUploadArgs();
		uploadProgress.set(3);
		this.#state = { state: 'upload_url_fetching', file, uri, crop, uploadProgress, getUploadArgsPromise };

		const getUploadArgsPromised = await getUploadArgsPromise;
		if (this.#isIdle) return { state: 'idle' };

		if (getUploadArgsPromised.error) {
			return (this.#state = {
				state: 'error',
				img: { url: uri, crop },
				errorMsgs: [getUploadArgsPromised.error.message, null],
			});
		}
		uploadProgress.set(10);

		const { bucketUrl, formDataFields } = getUploadArgsPromised.data;

		const formData = new FormData();
		for (const [key, value] of Object.entries(formDataFields)) {
			formData.append(key, value);
		}
		formData.append('file', file);

		/** Upload file to image storage (progress 10-90%) */
		const { abort: abortUpload, promise: imageUploadPromise } = this.#upload({
			bucketUrl,
			formData,
			uploadProgress: { tweened: uploadProgress, scale: 0.9 },
		});

		this.#cleanup = () => abortUpload();
		this.#state = {
			state: 'image_storage_uploading',
			crop,
			uri,
			imageUploadPromise,
			uploadProgress,
		};

		const imageUploadPromised = await imageUploadPromise;
		this.#cleanup = null;

		if (this.#isIdle) return { state: 'idle' };
		if (imageUploadPromised.error) {
			return (this.#state = {
				state: 'error',
				img: { url: uri, crop },
				errorMsgs: [imageUploadPromised.error.message, null],
			});
		}

		/** Save url to db (progress 90-100%) */
		const interval = setInterval(() => {
			uploadProgress.update((v) => {
				const newProgress = Math.min(v + 1, 100);
				if (newProgress === 100) clearInterval(interval);
				return newProgress;
			});
		}, 20);

		const saveToDbPromise = this.#saveToDb({ crop });

		this.#cleanup = () => clearInterval(interval);
		this.#state = {
			state: 'db_saving',
			crop,
			uri,
			saveToDbPromise,
			uploadProgress,
		};

		const saveToDbPromised = await saveToDbPromise;
		this.#cleanup = null;

		if (this.#isIdle) return { state: 'idle' };

		if (saveToDbPromised.error) {
			return (this.#state = {
				state: 'error',
				img: { url: uri, crop },
				errorMsgs: [saveToDbPromised.error.message, null],
			});
		}

		clearInterval(interval);
		uploadProgress.set(100);

		return (this.#state = { state: 'completed', savedImg: saveToDbPromised.data.savedImg });
	};
}

And... we're done! But not really. We have some dependency injection going on here. Namely, the five callback functions. Let's finish up our client code with the required UI components and then we'll tackle those callbacks.

UI

Base Components

We'll need a few components to show our UI state. We'll make them mostly dumb components so the logic can be isolated inside the file where the CropImgUploadController is instantiated.