Moderate profile pictures
Let users upload a profile picture from your app, show it everywhere as a small square thumbnail once it's allowed, and show a fallback avatar while it's checked or if it's blocked. Profile pictures appear next to everything a user does, so only an allowed picture is shown to other people.
1. Create an upload preset for avatars
In Portal › Upload presets, create a preset for profile pictures that accepts uploads with a token from your server, accepts image/* up to the size you allow, uses public visibility, keeps content after moderation and shows the decision to the uploader. A separate preset keeps avatars in their own folder with their own limits. Upload presets explains each setting.
2. Create an avatar lens
A lens is a named CDN transform. In Portal › Lenses, create one with the identifier avatar and an imageTransform with fit=cover, width=256 and height=256, or create it from code with client.ensureLens(). Each picture is resized once and the result is reused; see Lenses and transforms for the options and billing.
Request the lens on the rendered resource, which serves each browser the best format it accepts and also converts HEIC photos from phones:
https://cdn.calmlens.com/v1/assets/{projectId}/{assetId}/rendered/@avatar3. Sign a token for each new picture
Add an endpoint that checks the signed-in user and signs an upload token with client.uploadTokens.sign({ uploadPresetId, externalId, expiresInSeconds }). Set externalId to a new ID for each upload that names the user, such as avatar-<user ID>-<upload ID>. An externalId can belong to only one asset in a project, so a user's second picture needs a new one.
const { token, expiresAt } = await client.uploadTokens.sign({
uploadPresetId: "your-upload-preset-id",
externalId: "post-42",
expiresInSeconds: 600,
});
Return { token } from the endpoint. Each token uploads one file; see Upload tokens.
4. Upload from the profile page
Use the upload component from Build an upload component, pointed at the avatar preset and your avatar token endpoint. It uploads the file, shows the uploader when their picture is checked, allowed or blocked, and returns the asset ID:
import { CalmLensAppClient, CalmLensError } from "@calmlens/sdk/app";
import { type ChangeEvent, useState } from "react";
const MESSAGES: Record<string, string> = {
uploading: "Uploading…",
checking: "Checking your photo…",
pending: "Uploaded. We'll show it once it has been checked.",
allowed: "Uploaded.",
limited: "Uploaded.",
"age-restricted": "Uploaded. It will only be shown to people who can see sensitive content.",
blocked: "This photo can't be used because it may break our community guidelines.",
purged: "This photo can't be used because it breaks our community guidelines.",
"upload-preset/too-large": "Choose a smaller photo.",
"upload-preset/content-type-not-allowed": "Choose a JPEG, PNG or WebP photo.",
failed: "We couldn't upload this photo. Please try again.",
};
type Upload = { assetId: string; status: string };
async function uploadPhoto(file: File, onStage: (stage: string) => void): Promise<Upload> {
onStage("uploading");
const response = await fetch("/api/upload-token", { method: "POST" });
if (!response.ok) throw new Error("Upload refused");
const { token } = await response.json();
const client = new CalmLensAppClient({ uploadPresetId: "your-upload-preset-id", token });
let asset = await client.assets.create({
name: file.name,
content: { data: file, type: file.type },
});
onStage("checking");
for (let delay = 1000; asset.status === "pending" && delay <= 8000; delay *= 2) {
await new Promise((resolve) => setTimeout(resolve, delay));
asset = await client.assets.retrieve({ assetId: asset.id });
}
return { assetId: asset.id, status: asset.status ?? "pending" };
}
export function PhotoUpload({ onUploaded }: { onUploaded: (upload: Upload) => void }) {
const [stage, setStage] = useState<string | null>(null);
const busy = stage === "uploading" || stage === "checking";
async function handleChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
try {
const upload = await uploadPhoto(file, setStage);
setStage(upload.status);
if (upload.status !== "blocked" && upload.status !== "purged") onUploaded(upload);
} catch (error) {
const known = CalmLensError.isCalmLensError(error) && MESSAGES[error.code];
setStage(known ? error.code : "failed");
}
}
return (
<div>
<label>
Photo <input type="file" accept="image/*" disabled={busy} onChange={handleChange} />
</label>
{stage && busy && <progress aria-label={MESSAGES[stage]} />}
<p role="status">{stage ? MESSAGES[stage] : ""}</p>
</div>
);
}
Send the asset ID to your server. Retrieve the asset with client.assets.retrieve({ assetId }), check that its externalId is one you signed for this user, and store the asset ID and status as the user's new picture. Keep the previous picture until the new one is allowed, so the user isn't left without one while it's checked.
5. Update the picture from webhooks
Subscribe a webhook in Portal › Webhooks & connectors to asset.allowed, asset.limited, asset.age-restricted, asset.blocked, asset.purged and asset.failed, and match each event's externalId to the user:
import { CalmLensError, parseWebhookEvent } from "@calmlens/sdk/server";
const STATUS_EVENTS = new Set(["asset.allowed", "asset.limited", "asset.age-restricted", "asset.blocked", "asset.purged"]);
export async function POST(request: Request): Promise<Response> {
let event;
try {
event = await parseWebhookEvent({
payload: await request.text(), headers: request.headers,
secret: (process.env.CALMLENS_WEBHOOK_SECRET ?? ""),
});
} catch (error) {
if (CalmLensError.isCalmLensError(error)) return new Response(null, { status: 401 });
throw error;
}
const asset = event.data.asset;
if (asset?.externalId && STATUS_EVENTS.has(event.type)) {
// Store the new status and re-render from it. Skip events older than the one you stored.
await updateModerationStatus(asset.externalId, asset.classification.moderation.status, event.createdAt);
} else if (asset?.externalId && event.type === "asset.failed") {
await markModerationFailed(asset.externalId);
}
return new Response(null, { status: 204 });
}
When the new picture is allowed, make it the user's picture and delete the old asset if you no longer need it. If a reviewer later blocks or purges the picture, the stored status changes and the fallback appears; see Status changes after publishing.
6. Render the avatar with a fallback
Show the lens image only when the stored status is allowed, and the user's initial otherwise. The image also falls back if the CDN refuses it, for example after a status change your page hasn't loaded yet.
import { getAssetUrl } from "@calmlens/sdk/app";
import { useState } from "react";
type AvatarProps = { projectId: string; assetId?: string; status?: string; name: string };
export function Avatar({ projectId, assetId, status, name }: AvatarProps) {
const [failed, setFailed] = useState(false);
if (!assetId || (status !== "allowed" && status !== "limited") || failed) {
return (
<span className="avatar" role="img" aria-label={name}>
{name.charAt(0).toUpperCase()}
</span>
);
}
const src = getAssetUrl({
projectId,
assetIdentifier: assetId,
resource: "rendered",
lens: "avatar",
});
return <img className="avatar" src={src} alt={name} onError={() => setFailed(true)} />;
}
| Status | The user | Everyone else |
|---|---|---|
| pending | Their previous picture, and a local preview marked as being checked | Their previous picture or the fallback |
| allowed or limited | The picture | The picture |
| age-restricted | A message asking for a different picture | The fallback |
| blocked or purged | A message that the picture can't be used | The fallback |
Profile pictures appear in places where a reveal control doesn't fit, so treat age-restricted pictures as not shown. For images that should stay visible to some viewers, see Show sensitive media. Using the decision is the reference for each status.
7. Test it
Upload the test input images for your project as profile pictures to check the fallback, the uploader's messages and your webhook without real harmful content.