109 lines
3.8 KiB
TypeScript
109 lines
3.8 KiB
TypeScript
import {
|
|
deleteCloudinaryImageByUrl,
|
|
verifyCloudinaryWebhookSignature,
|
|
} from "~/lib/cloudinary.server";
|
|
import { logger } from "~/lib/logger";
|
|
import { findTeamById, updateTeam } from "~/models/team";
|
|
import { findUserById, updateUser } from "~/models/user";
|
|
import type { Route } from "./+types/api.webhooks.cloudinary";
|
|
|
|
type CloudinaryContext = Record<string, unknown> | string | undefined;
|
|
|
|
interface CloudinaryWebhookPayload {
|
|
secure_url?: string;
|
|
moderation?: Array<{ status?: string }>;
|
|
moderation_status?: string;
|
|
info?: {
|
|
moderation?: { status?: string } | Array<{ status?: string }>;
|
|
};
|
|
context?: CloudinaryContext | { custom?: Record<string, unknown> };
|
|
}
|
|
|
|
function getContextValue(context: CloudinaryWebhookPayload["context"], key: string): string | null {
|
|
if (!context) return null;
|
|
|
|
if (typeof context === "string") {
|
|
const pairs = context.split("|").map((part) => part.split("="));
|
|
const found = pairs.find(([pairKey]) => pairKey === key);
|
|
return found?.[1] ?? null;
|
|
}
|
|
|
|
const custom = "custom" in context && context.custom && typeof context.custom === "object"
|
|
? context.custom
|
|
: context;
|
|
const value = (custom as Record<string, unknown>)[key];
|
|
return typeof value === "string" ? value : null;
|
|
}
|
|
|
|
function moderationStatus(payload: CloudinaryWebhookPayload): string | null {
|
|
const direct = payload.moderation?.[0]?.status ?? payload.moderation_status;
|
|
if (direct) return direct;
|
|
|
|
const infoModeration = payload.info?.moderation;
|
|
if (Array.isArray(infoModeration)) return infoModeration[0]?.status ?? null;
|
|
return infoModeration?.status ?? null;
|
|
}
|
|
|
|
export async function action({ request }: Route.ActionArgs) {
|
|
const body = await request.text();
|
|
const signature = request.headers.get("X-Cld-Signature");
|
|
const timestamp = request.headers.get("X-Cld-Timestamp");
|
|
|
|
if (!verifyCloudinaryWebhookSignature({ body, signature, timestamp })) {
|
|
return Response.json({ error: "Invalid signature" }, { status: 401 });
|
|
}
|
|
|
|
let payload: CloudinaryWebhookPayload;
|
|
try {
|
|
payload = JSON.parse(body) as CloudinaryWebhookPayload;
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
const status = moderationStatus(payload);
|
|
if (status !== "approved") {
|
|
logger.info("Cloudinary avatar not approved:", { status });
|
|
return Response.json({ status: "ignored" });
|
|
}
|
|
|
|
if (!payload.secure_url) {
|
|
return Response.json({ error: "Missing secure_url" }, { status: 400 });
|
|
}
|
|
|
|
const target = getContextValue(payload.context, "avatar_target");
|
|
const entityId = getContextValue(payload.context, "entity_id");
|
|
|
|
if (!target || !entityId) {
|
|
return Response.json({ error: "Missing avatar context" }, { status: 400 });
|
|
}
|
|
|
|
if (target === "user") {
|
|
const existingUser = await findUserById(entityId);
|
|
await updateUser(entityId, {
|
|
customAvatarUrl: payload.secure_url,
|
|
avatarType: "uploaded",
|
|
});
|
|
if (existingUser?.customAvatarUrl && existingUser.customAvatarUrl !== payload.secure_url) {
|
|
deleteCloudinaryImageByUrl(existingUser.customAvatarUrl).catch((error) => {
|
|
logger.error("Failed to delete replaced user avatar from Cloudinary:", error);
|
|
});
|
|
}
|
|
return Response.json({ status: "updated" });
|
|
}
|
|
|
|
if (target === "team") {
|
|
const existingTeam = await findTeamById(entityId);
|
|
await updateTeam(entityId, {
|
|
logoUrl: payload.secure_url,
|
|
avatarType: "uploaded",
|
|
});
|
|
if (existingTeam?.logoUrl && existingTeam.logoUrl !== payload.secure_url) {
|
|
deleteCloudinaryImageByUrl(existingTeam.logoUrl).catch((error) => {
|
|
logger.error("Failed to delete replaced team avatar from Cloudinary:", error);
|
|
});
|
|
}
|
|
return Response.json({ status: "updated" });
|
|
}
|
|
|
|
return Response.json({ error: "Unknown avatar target" }, { status: 400 });
|
|
}
|