// A project avatar is an image the hub stores (R-project-avatars, // R-project-avatar-crop-flow). The hub normalizes every upload to one square // PNG per project and serves it from the project avatar route. No custom // avatar means the generated initial-and-color avatar. // Shared by the registry, the JSON API, the web editor, and the CLI. Keep this // module free of package dependencies because the installed CLI imports it. export const MAX_AVATAR_UPLOAD_BYTES = 5 * 1024 * 1024; /** Side of the stored square avatar, in pixels. */ export const AVATAR_IMAGE_SIZE = 512; export const AVATAR_CONTENT_TYPES = [ "image/png", "image/jpeg", "image/webp", "image/gif", "image/avif", ] as const; export type AvatarContentType = (typeof AVATAR_CONTENT_TYPES)[number]; const EXTENSION_CONTENT_TYPES: Readonly> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", avif: "image/avif", }; export const AVATAR_TYPE_ERROR = "Avatar images must be PNG, JPEG, WebP, GIF, or AVIF."; export const AVATAR_SIZE_ERROR = "Avatar images must be smaller than 5 MB."; export const AVATAR_EMPTY_ERROR = "The avatar image is empty."; /** Read the media type of a Content-Type header, without its parameters. */ export const avatarContentType = (value: unknown): AvatarContentType | undefined => { if (typeof value !== "string") return undefined; const mediaType = value.split(";")[0].trim().toLowerCase(); return AVATAR_CONTENT_TYPES.find((type) => type === mediaType); }; /** Read the media type from a file name, for CLI uploads. */ export const avatarContentTypeForFile = (fileName: string): AvatarContentType | undefined => { const extension = fileName.toLowerCase().split(".").pop(); return extension ? EXTENSION_CONTENT_TYPES[extension] : undefined; }; /** Validate one avatar upload before the hub reads its bytes. */ export const avatarUploadError = ( contentType: unknown, byteLength: number, ): string | undefined => { if (!avatarContentType(contentType)) return AVATAR_TYPE_ERROR; if (byteLength <= 0) return AVATAR_EMPTY_ERROR; if (byteLength > MAX_AVATAR_UPLOAD_BYTES) return AVATAR_SIZE_ERROR; return undefined; }; /** * URL of a stored project avatar. The version changes on every upload, so * browsers load the new image instead of the cached one. */ export const projectAvatarUrl = ( projectId: string, version: string | null | undefined, ): string | undefined => version ? `/api/projects/${encodeURIComponent(projectId)}/avatar?v=${encodeURIComponent(version)}` : undefined;