117 lines
3.3 KiB
TypeScript
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);
|
|
}
|