brackt/app/lib/cloudinary.server.ts
2026-05-07 15:54:10 -07:00

117 lines
3.3 KiB
TypeScript

import { v2 as cloudinary } from "cloudinary";
type AvatarTarget = "user" | "team";
interface UploadAvatarImageArgs {
buffer: Buffer;
contentType: string;
target: AvatarTarget;
entityId: string;
notificationUrl: string;
}
interface UploadSportIconArgs {
buffer: Buffer;
slug?: string | null;
}
function requireCloudinaryConfig() {
const cloudName = process.env.CLOUDINARY_CLOUD_NAME;
const apiKey = process.env.CLOUDINARY_API_KEY;
const apiSecret = process.env.CLOUDINARY_API_SECRET;
if (!cloudName || !apiKey || !apiSecret) {
throw new Error("Cloudinary environment variables are not configured");
}
cloudinary.config({
cloud_name: cloudName,
api_key: apiKey,
api_secret: apiSecret,
secure: true,
});
}
function toDataUri(buffer: Buffer, contentType: string): string {
return `data:${contentType};base64,${buffer.toString("base64")}`;
}
export async function uploadAvatarImage({
buffer,
contentType,
target,
entityId,
notificationUrl,
}: UploadAvatarImageArgs) {
requireCloudinaryConfig();
return await cloudinary.uploader.upload(toDataUri(buffer, contentType), {
resource_type: "image",
folder: "avatars",
public_id: `${target}-${entityId}-${Date.now()}`,
overwrite: true,
moderation: "aws_rek",
notification_url: notificationUrl,
context: {
avatar_target: target,
entity_id: entityId,
},
transformation: [{ width: 256, height: 256, crop: "fill", gravity: "face" }],
});
}
function normalizePublicIdPart(value: string | null | undefined): string {
const normalized = value?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
return normalized || "sport";
}
export async function uploadSportIcon({ buffer, slug }: UploadSportIconArgs) {
requireCloudinaryConfig();
return await cloudinary.uploader.upload(toDataUri(buffer, "image/svg+xml"), {
resource_type: "image",
folder: "sports-icons",
public_id: `${normalizePublicIdPart(slug)}-${Date.now()}`,
allowed_formats: ["svg"],
overwrite: false,
});
}
export function cloudinaryPublicIdFromUrl(url: string | null | undefined): string | null {
if (!url) return null;
if (!url.includes("res.cloudinary.com") || !url.includes("/image/upload/")) return null;
const path = url.split("?")[0];
const match = path.match(/\/(?:v\d+\/)?((?:avatars|sports-icons)\/[^.]+)/);
return match?.[1] ?? null;
}
export function buildWebhookUrl(request: Request): string {
const baseUrl = process.env.APP_URL ?? process.env.BETTER_AUTH_URL ?? new URL(request.url).origin;
return new URL("/api/webhooks/cloudinary", baseUrl).toString();
}
export async function deleteCloudinaryImageByUrl(url: string | null | undefined): Promise<void> {
const publicId = cloudinaryPublicIdFromUrl(url);
if (!publicId) return;
requireCloudinaryConfig();
await cloudinary.uploader.destroy(publicId, { resource_type: "image", invalidate: true });
}
export function verifyCloudinaryWebhookSignature({
body,
signature,
timestamp,
}: {
body: string;
signature: string | null;
timestamp: string | null;
}): boolean {
requireCloudinaryConfig();
if (!signature || !timestamp) return false;
const parsedTimestamp = Number(timestamp);
if (!Number.isFinite(parsedTimestamp)) return false;
return cloudinary.utils.verifyNotificationSignature(body, parsedTimestamp, signature);
}