Add profile pictures
Let people upload a PNG or paste an image URL for their profile picture, fetch the URL safely in a background job, and draw a placeholder for everyone else.
Last updated
After reading this guide, you will know
- What guides build profile pictures, and in what order to follow them
- How a pasted URL goes through the same checks as an uploaded file
- When a copied image keeps the account's old picture
At the end of this recipe every account has a profile picture: a square PNG the person uploaded, one copied from a URL they pasted, or an identicon drawn from their id. Both sources pass the same size and format checks before anything is stored.
npm add @sdxc/jobs @sdxc/outbound @sdxc/png @sdxc/resultAccept an upload and draw a placeholder
Uploads and placeholders cover every account before any URL is involved, and the checks they
write are the ones a copied image goes through. Follow
Accept PNG avatars and draw placeholders for
readAvatar, cropSquare, avatars() and the route that serves either picture.
Refuse a bad URL where it is pasted
A URL that can never be fetched is refused while the person is still on the form. Check it as
Refuse the URL at the endpoint
shows, then enqueue an avatars.copy job with userId and the checked url, declared as
Background jobs and cron describes.
Copy the image in a job
The job follows the URL under one deadline and a byte cap, as Follow and read it under one deadline explains, then hands the bytes to the upload's own checks:
import { createJobHandler } from "@sdxc/jobs";
import { follow, readBytes, release } from "@sdxc/outbound";
import { encode } from "@sdxc/png";
import { isFailure } from "@sdxc/result";
import jobs from "~/app/jobs";
import { avatars } from "~/app/models/avatars";
import { cropSquare } from "~/app/services/avatars/crop-square";
import { readAvatar } from "~/app/services/avatars/read-avatar";
const MAX_BYTES = 2 * 1024 * 1024;
export default createJobHandler(jobs.avatars.copy, async (ctx) => {
let followed = await follow(ctx.input.url, {
headers: { accept: "image/png" },
timeout: "10 seconds",
maxRedirects: 5,
ports: "default",
});
if (isFailure(followed)) {
if (followed.error.retryable) return ctx.retry({ cause: followed.error });
return ctx.exit(followed.error.code);
}
let { response } = followed.data;
if (!response.ok) {
release(response.body);
return ctx.exit(`The image answered ${response.status}`);
}
let body = await readBytes(response, { maxBytes: MAX_BYTES });
if (isFailure(body)) return ctx.exit(body.error.code);
let image = await readAvatar(new File([body.data.data.slice()], "avatar.png"));
if (isFailure(image)) return ctx.exit(image.error.code);
let bytes = await encode(cropSquare(image.data));
let key = `avatars/${ctx.input.userId}.png`;
await avatars().set(key, new File([bytes.slice()], key, { type: "image/png" }));
});A URL that fails any check ends the job, and the account keeps the picture it had.
Test it
Test each stage as its guide does: Test on pixels decodes what was stored and checks its pixels, and Test it with MSW answers the pasted URL without a network.
Where to go next
Draw social card images — drawing on a canvas that encodes to PNG.
Check where the name points — resolving each hop over DNS before the job requests it.
Cache on Cloudflare Workers — serving pictures without reading the bucket on every request.