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
<script lang="ts">
import { fade } from 'svelte/transition';
import { uploadS3PresignedPost, CropImgUploadController } from '$lib/cloudStorage/client';
import { FileInput } from '$lib/components';
import { ImageCrop, UploadProgress, ImageCardBtns, ImageCardOverlays } from '$lib/image/client';
import ConfirmDelAvatarModal from './ConfirmDelAvatarModal.svelte';
import { updateAvatarCrop } from './avatar/crop.json';
import {
MAX_UPLOAD_SIZE,
getSignedAvatarUploadUrl,
checkAndSaveUploadedAvatar,
deleteAvatar,
} from './avatar/upload.json';
interface Props {
avatar: DB.User['avatar'];
onNewAvatar: (img: DB.User['avatar']) => void | Promise<void>;
onCancel: () => void;
}
const { avatar, onCancel, onNewAvatar }: Props = $props();
const controller = new CropImgUploadController({
saveCropToDb: updateAvatarCrop.send,
delImg: deleteAvatar.send,
getUploadArgs: getSignedAvatarUploadUrl.send,
upload: uploadS3PresignedPost,
saveToDb: checkAndSaveUploadedAvatar.send,
});
if (avatar) controller.toCropPreexisting(avatar);
else controller.toFileSelect();
const s = $derived(controller.value);
let deleteConfirmationModalOpen = $state(false);
</script>
{#if s.state === 'file_selecting'}
<FileInput
onSelect={async (files: File[]) => {
const erroredCanceledOrLoaded = await s.loadFiles({ files, MAX_UPLOAD_SIZE });
if (erroredCanceledOrLoaded.state === 'uri_loaded') erroredCanceledOrLoaded.startCrop();
}}
accept="image/jpeg, image/png"
/>
{:else}
<div class="relative aspect-square h-80 w-80 sm:h-[32rem] sm:w-[32rem]">
{#if s.state === 'error'}
<ImageCardOverlays
img={{ kind: 'overlay', url: s.img.url, crop: s.img.crop, blur: true }}
overlay={{ red: true }}
{onCancel}
errorMsgs={s.errorMsgs}
/>
{:else if s.state === 'cropping_preexisting'}
<ImageCrop
url={s.url}
crop={s.crop}
onSave={async (crop) => {
const erroredCanceledOrCompleted = await s.saveCropValToDb({ crop });
if (erroredCanceledOrCompleted.state === 'completed') await onNewAvatar(erroredCanceledOrCompleted.savedImg);
}}
/>
<ImageCardBtns {onCancel} onNew={controller.toFileSelect} onDelete={() => (deleteConfirmationModalOpen = true)} />
<ConfirmDelAvatarModal
bind:open={deleteConfirmationModalOpen}
handleDelete={async () => {
const erroredCanceledOrCompleted = await s.deleteImg();
if (erroredCanceledOrCompleted.state === 'completed') await onNewAvatar(erroredCanceledOrCompleted.savedImg);
}}
/>
{:else if s.state === 'db_updating_preexisting'}
<ImageCardOverlays img={{ kind: 'overlay', url: s.url, crop: s.crop }} />
<ImageCardBtns loader />
{:else if s.state === 'deleting_preexisting'}
<ImageCardOverlays img={{ kind: 'overlay', url: s.url, crop: s.crop }} overlay={{ pulsingWhite: true }} />
<ImageCardBtns loader />
{:else if s.state === 'uri_loading'}
<ImageCardOverlays img={{ kind: 'skeleton' }} />
{:else if s.state === 'uri_loaded'}
<ImageCardOverlays img={{ kind: 'overlay', url: s.uri, crop: null }} />
{:else if s.state === 'cropping'}
<ImageCrop
url={s.uri}
onSave={async (crop) => {
const erroredCanceledOrCompleted = await s.loadCropValue({ crop }).uploadCropped();
if (erroredCanceledOrCompleted.state === 'completed') await onNewAvatar(erroredCanceledOrCompleted.savedImg);
}}
/>
<ImageCardBtns {onCancel} onNew={controller.toFileSelect} />
{:else if s.state === 'cropped'}
<ImageCardOverlays img={{ kind: 'overlay', url: s.uri, crop: s.crop }} />
{:else if s.state === 'upload_url_fetching' || s.state === 'image_storage_uploading' || s.state === 'db_saving'}
<ImageCardOverlays img={{ kind: 'overlay', url: s.uri, crop: s.crop }} overlay={{ pulsingWhite: true }} />
<ImageCardBtns loader />
<div out:fade><UploadProgress uploadProgress={s.uploadProgress} /></div>
{:else if s.state === 'completed'}
<ImageCardOverlays img={{ kind: 'full', url: s.savedImg?.url, crop: s.savedImg?.crop }} />
<ImageCardBtns badgeCheck />
{:else if s.state === 'idle'}
<!-- -->
{/if}
</div>
{/if}
import { circOut } from 'svelte/easing';
import { tweened, type Tweened } from 'svelte/motion';
import { defaultCropValue, fileToDataUrl, humanReadableFileSize } from '$lib/image/client';
import type { CroppedImg, CropValue } from '$lib/image/common';
import type { Result } from '$lib/utils/common';
//#region States
//#region Static
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 };
//#endregion Static
//#region Preexisting Image
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>>;
};
//#endregion Preexisting Image
//#region New Image
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>;
};
//#endregion New Image
export type CropControllerState =
| Idle
| Err
| Completed
| CroppingPreexisting
| DbUpdatingPreexisting
| DeletingPreexisting
| FileSelecting
| UriLoading
| UriLoaded
| Cropping
| Cropped
| UploadUrlFetching
| ImageStorageUploading
| DbSaving;
export type StateName = CropControllerState['state'];
//#endregion States
//#region Callbacks
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 }>>;
//#endregion Callbacks
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);
}
/** 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 });
};
/** Clean up and move to 'file_selecting' */
toFileSelect = () => {
this.#cleanup?.();
this.#state = { state: 'file_selecting', loadFiles: this.#loadFiles };
};
#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 }),
});
};
/** Get the upload url, upload the file, and save it to the DB.
* States: any -> 'upload_url_fetching' -> 'image_storage_uploading' -> 'db_saving' -> 'completed'
* */
#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 });
};
}
import { logger } from '$lib/logging/client';
import type { Result } from '$lib/utils/common';
import type { Tweened } from 'svelte/motion';
export type UploaderArgs = {
bucketUrl: string;
formData: FormData;
uploadProgress?: { scale?: number | undefined; tweened?: Tweened<number> | undefined } | undefined;
};
export type UploaderRes = { promise: Promise<Result<{ status: number }>>; abort: () => void };
export type Uploader = (a: UploaderArgs) => UploaderRes;
export const uploadS3PresignedPost: Uploader = ({ bucketUrl, formData, uploadProgress }) => {
const req = new XMLHttpRequest();
req.open('POST', bucketUrl);
if (uploadProgress?.tweened) {
req.upload.addEventListener('progress', (e) =>
uploadProgress.tweened?.update((p) => Math.max(p, (e.loaded / e.total) * 100 * (uploadProgress?.scale ?? 1))),
);
}
const promise = new Promise<Result<{ status: number }>>((resolve) => {
req.onreadystatechange = () => {
if (req.readyState === 4) {
if (req.status >= 200 && req.status < 300) {
resolve({ data: { status: req.status } });
} else if (req.status === 0) {
resolve({ error: { status: 499, message: 'Upload aborted' } });
} else {
logger.error(`Error uploading file. Status: ${req.status}. Response: ${req.responseText}`);
resolve({ error: { status: req.status, message: 'Error uploading file' } });
}
}
};
});
req.send(formData);
return { abort: req.abort.bind(req), promise };
};
import { ClientFetcher } from '$lib/http/client.svelte';
import type { CroppedImg, CropValue } from '$lib/image/common';
import type { RouteId } from './$types';
type PutReq = { crop: CropValue };
export type PutRes = { savedImg: CroppedImg };
export const updateAvatarCrop = new ClientFetcher<RouteId, PutRes, PutReq>('PUT', '/account/profile/avatar/crop.json');
import { eq } from 'drizzle-orm';
import { db, users } from '$lib/db/server';
import { jsonFail, jsonOk } from '$lib/http/server';
import { croppedImgSchema } from '$lib/image/common';
import type { PutRes } from '.';
import type { RequestHandler } from './$types';
import type { RequestEvent } from '@sveltejs/kit';
const updateAvatarCrop = async ({ locals, request }: RequestEvent) => {
const { user } = await locals.seshHandler.userOrRedirect();
if (!user.avatar) return jsonFail(400, 'No avatar to crop');
const body = await request.json().catch(() => null);
const parsed = croppedImgSchema.safeParse({ ...body, url: user.avatar.url });
if (!parsed.success) return jsonFail(400);
const avatar = parsed.data;
await db.update(users).set({ avatar }).where(eq(users.id, user.id));
return jsonOk<PutRes>({ savedImg: avatar });
};
export const PUT: RequestHandler = updateAvatarCrop;
import { z } from 'zod';
import { ClientFetcher } from '$lib/http/client.svelte';
import { cropSchema, type CroppedImg } from '$lib/image/common';
import type { Result } from '$lib/utils/common';
import type { RouteId } from './$types';
const routeId = '/account/profile/avatar/upload.json';
export const MAX_UPLOAD_SIZE = 1024 * 1024 * 3; // 3MB
export type GetRes = { bucketUrl: string; objectKey: string; formDataFields: Record<string, string> };
export const putReqSchema = z.object({ crop: cropSchema });
export type PutReq = z.infer<typeof putReqSchema>;
export type PutRes = { savedImg: CroppedImg | null };
export const getSignedAvatarUploadUrl = new ClientFetcher<RouteId, GetRes>('GET', routeId);
export const checkAndSaveUploadedAvatar = new ClientFetcher<RouteId, PutRes, PutReq>('PUT', routeId);
export const deleteAvatar = new ClientFetcher<RouteId, Result.Success>('DELETE', routeId);
import { eq } from 'drizzle-orm';
import { createLimiter } from '$lib/botProtection/rateLimit/server';
import {
createUnsavedUploadCleaner,
deleteS3Object,
generateS3UploadPost,
invalidateCloudfront,
keyController,
} from '$lib/cloudStorage/server';
import { detectModerationLabels } from '$lib/cloudStorage/server';
import { db, presigned, users } from '$lib/db/server';
import { jsonFail, jsonOk } from '$lib/http/server';
import { toHumanReadableTime } from '$lib/utils/common';
import { MAX_UPLOAD_SIZE, putReqSchema, type GetRes, type PutRes } from '.';
import type { RequestHandler } from './$types';
import type { RequestEvent } from '@sveltejs/kit';
// generateS3UploadPost enforces max upload size and denies any upload that we don't sign
// uploadLimiter rate limits the number of uploads a user can do
// presigned ensures we don't have to trust the client to tell us what the uploaded objectUrl is after the upload
// unsavedUploadCleaner ensures that we don't miss cleaning up an object in S3 if the user doesn't notify us of the upload
// detectModerationLabels prevents explicit content
const EXPIRE_SECONDS = 60;
const uploadLimiter = createLimiter({
id: 'checkAndSaveUploadedAvatar',
limiters: [
{ kind: 'global', rate: [300, 'd'] },
{ kind: 'userId', rate: [2, '15m'] },
{ kind: 'ipUa', rate: [3, '15m'] },
],
});
const unsavedUploadCleaner = createUnsavedUploadCleaner({
jobDelaySeconds: EXPIRE_SECONDS,
getStoredUrl: async ({ userId }) =>
(await db.select().from(users).where(eq(users.id, userId)).limit(1))[0]?.avatar?.url,
});
const getSignedAvatarUploadUrl = async (event: RequestEvent) => {
const { locals } = event;
const { user } = await locals.seshHandler.userOrRedirect();
const rateCheck = await uploadLimiter.check(event, { log: { userId: user.id } });
if (rateCheck.forbidden) return jsonFail(403);
if (rateCheck.limiterKind === 'global')
return jsonFail(
429,
`This demo has hit its 24h max. Please try again in ${toHumanReadableTime(rateCheck.retryAfterSec)}`,
);
if (rateCheck.limited) return jsonFail(429, rateCheck.humanTryAfter('uploads'));
const key = keyController.create.user.avatar({ userId: user.id });
const res = await generateS3UploadPost({
key,
maxContentLength: MAX_UPLOAD_SIZE,
expireSeconds: EXPIRE_SECONDS,
});
if (!res) return jsonFail(500, 'Failed to generate upload URL');
await presigned.insert({ bucketUrl: keyController.transform.keyToS3Url(key), userId: user.id, key });
unsavedUploadCleaner.addDelayedJob({
cloudfrontUrl: keyController.transform.keyToCloudfrontUrl(key),
userId: user.id,
});
return jsonOk<GetRes>({ bucketUrl: res.bucketUrl, formDataFields: res.formDataFields, objectKey: key });
};
const checkAndSaveUploadedAvatar = async (event: RequestEvent) => {
const { request, locals } = event;
const { user } = await locals.seshHandler.userOrRedirect();
const body = await request.json().catch(() => null);
const parsed = putReqSchema.safeParse(body);
if (!parsed.success) return jsonFail(400);
const presignedObjectUrl = await presigned.get({ userId: user.id });
if (!presignedObjectUrl) return jsonFail(400);
const cloudfrontUrl = keyController.transform.s3UrlToCloudfrontUrl(presignedObjectUrl.bucketUrl);
const imageExists = await fetch(cloudfrontUrl, { method: 'HEAD' }).then((res) => res.ok);
if (!imageExists) {
await presigned.delete({ userId: user.id });
unsavedUploadCleaner.removeJob({ cloudfrontUrl });
return jsonFail(400);
}
const newAvatar = { crop: parsed.data.crop, url: cloudfrontUrl };
const oldAvatar = user.avatar;
const newKey = keyController.transform.s3UrlToKey(presignedObjectUrl.bucketUrl);
const { error: moderationError } = await detectModerationLabels({ s3Key: newKey });
if (moderationError) {
unsavedUploadCleaner.removeJob({ cloudfrontUrl });
await Promise.all([deleteS3Object({ key: newKey, guard: null }), presigned.delete({ userId: user.id })]);
return jsonFail(422, moderationError.message);
}
if (keyController.is.cloudfrontUrl(oldAvatar?.url) && newAvatar.url !== oldAvatar.url) {
const oldKey = keyController.transform.cloudfrontUrlToKey(oldAvatar.url);
await Promise.all([
deleteS3Object({ key: oldKey, guard: () => keyController.guard.user.avatar({ key: oldKey, ownerId: user.id }) }),
invalidateCloudfront({ keys: [oldKey] }),
]);
}
unsavedUploadCleaner.removeJob({ cloudfrontUrl });
await Promise.all([
presigned.delete({ userId: user.id }),
db.update(users).set({ avatar: newAvatar }).where(eq(users.id, user.id)),
]);
return jsonOk<PutRes>({ savedImg: newAvatar });
};
const deleteAvatar = async ({ locals }: RequestEvent) => {
const { user } = await locals.seshHandler.userOrRedirect();
if (!user.avatar) return jsonFail(404, 'No avatar to delete');
const promises: Array<Promise<unknown>> = [db.update(users).set({ avatar: null }).where(eq(users.id, user.id))];
if (keyController.is.cloudfrontUrl(user.avatar.url)) {
const key = keyController.transform.cloudfrontUrlToKey(user.avatar.url);
promises.push(
deleteS3Object({ key, guard: () => keyController.guard.user.avatar({ key, ownerId: user.id }) }),
invalidateCloudfront({ keys: [key] }),
);
}
await Promise.all(promises);
return jsonOk<PutRes>({ savedImg: null });
};
export const GET: RequestHandler = getSignedAvatarUploadUrl;
export const PUT: RequestHandler = checkAndSaveUploadedAvatar;
export const DELETE: RequestHandler = deleteAvatar;
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
- AWS S3 (image storage)
- AWS Cloudfront (distribution)
- AWS Rekognition (content moderation)
- AWS IAM (security)
- PostgreSQL with Drizzle ORM (to store the user and their avatar)
- Redis (rate limiting)
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
- Upload state machine controller with Svelte
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.
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
andEventSource
.
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
withreq.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):
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 Type
s and Interface
s 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.
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 Breakpoint | Reasoning |
---|---|
There's a decision branch | Allow for easily composable pipelines |
An async function is being awaited | Allow 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 UI | Allow getting user input, showing spinners, updating progress bars, etc. |
Static
We need some 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.
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.
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:
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.
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
:
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.
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 }),
});
};
}
export const fileToDataUrl = (
blob: Blob,
): Promise<{ uri: string; error?: never } | { uri?: never; error: Error | DOMException }> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (_e) => resolve({ uri: reader.result as string });
reader.onerror = (_e) => resolve({ error: reader.error ?? new Error('Unknown error') });
reader.onabort = (_e) => resolve({ error: new Error('Read aborted') });
reader.readAsDataURL(blob);
});
};
export const humanReadableFileSize = (size: number) => {
if (size < 1024) return `${size}B`;
if (size < 1024 * 1024) return `${(size / 1024).toPrecision(2)}kB`;
return `${(size / (1024 * 1024)).toPrecision(2)}MB`;
};
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.
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.