Skip to content

Instantly share code, notes, and snippets.

@sannajammeh
Last active October 21, 2023 14:37
Show Gist options
  • Save sannajammeh/a25cb19e0be83f09f2a8fd73d2c41428 to your computer and use it in GitHub Desktop.
Save sannajammeh/a25cb19e0be83f09f2a8fd73d2c41428 to your computer and use it in GitHub Desktop.
PayloadCMS Bunny Cloud adapters

WIP Bunny adapters for Payload plugin cloud storage

Confirmed to work on Payload 1.0, not yet tested 2.0.

Usage

// payload.config.ts
plugins: [
		cloudStorage({
			collections: {
				[Media.slug]: {
					adapter: bunnyStorage({
						zone: env.BUNNY_ZONE,
						region: "se",
						accessKey: env.BUNNY_ACCESS_KEY,
						pullZone: new URL("https://chew.b-cdn.net"),
					}),
					disablePayloadAccessControl: true,
				},
        			[RecipeVideo.slug]: {
					adapter: bunnyStream({
						accessKey: env.BUNNY_STREAM_API_KEY,
						libraryId: env.BUNNY_STREAM_LIBRARY_ID,
						zone: env.BUNNY_STREAM_ZONE,
						collectionId: env.BUNNY_STREAM_RECIPES_COLLECTION,
					}),
					disablePayloadAccessControl: true,
					prefix: "videos",
				},
    })
  ]
import type {
GenerateURL,
HandleDelete,
StaticHandler,
Adapter,
GeneratedAdapter,
HandleUpload,
} from "@payloadcms/plugin-cloud-storage/dist/types";
import { getFilePrefix } from "@payloadcms/plugin-cloud-storage/dist/utilities/getFilePrefix";
import path from "path";
/**
* The BunnyStorageAdapter is a custom adapter for BunnyCDN. Prefer using the bunnyStorage function to create an instance of this class.
* @internal
*/
export class BunnyStorageAdapter implements GeneratedAdapter {
constructor(
private readonly config: BunnyAdapterConfig,
private readonly adapterArgs: Parameters<Adapter>[0],
) {
this.regionPrefix = this.config.region
? this.config.region === "default"
? ""
: `${this.config.region}.`
: "";
}
private regionPrefix: string;
handleUpload: HandleUpload = async ({ data, file, collection }) => {
const key = this.getKey(data.filename);
const url = `https://${this.regionPrefix}storage.bunnycdn.com/${this.config.zone}/${key}`;
const response = await fetch(url, {
method: "PUT",
headers: {
AccessKey: this.config.accessKey,
"Content-Type": "application/octet-stream",
},
body: file.buffer,
});
if (!response.ok) {
throw new Error(`Error uploading file: ${response.statusText}`);
}
return data;
};
handleDelete: HandleDelete = async ({ filename, doc: { prefix = "" } }) => {
const key = this.getKey(path.posix.join(prefix, filename));
const url = `https://${this.regionPrefix}storage.bunnycdn.com/${this.config.zone}/${key}`;
const response = await fetch(url, {
method: "DELETE",
headers: {
AccessKey: this.config.accessKey,
},
});
const txt = await response.text();
if (!response.ok) {
throw new Error(`Error deleting file: ${response.statusText}`);
}
};
generateURL: GenerateURL = ({ filename, prefix = "", collection }) => {
const { pullZone, zone, useZoneInUrl } = this.config;
const url =
pullZone instanceof URL
? new URL(pullZone)
: new URL(
pullZone.startsWith("http") // Assume it's a full URL to BunnyCDN
? pullZone
: `https://${pullZone}.b-cdn.net`,
);
url.pathname = path.posix.join(
url.pathname,
useZoneInUrl ? zone : "",
prefix,
filename,
);
return url.href;
};
staticHandler: StaticHandler = async (req, res, next) => {
try {
const prefix = await getFilePrefix({
req,
collection: this.adapterArgs.collection,
});
const url = `https://${this.regionPrefix}storage.bunnycdn.com/${
this.config.zone
}/${path.posix.join(prefix, req.params.filename)}`;
const response = await fetch(url, {
method: "GET",
headers: {
accept: "*/*",
AccessKey: this.config.accessKey,
},
});
response.headers.forEach((header, key) => {
res.setHeader(key, header);
});
res.status(response.status);
res.send(await response.blob());
} catch (error) {
console.error((error as any).message);
return next();
}
};
private getKey = (filename: string, prefix?: string): string => {
const _prefix = prefix || this.adapterArgs.prefix || "";
return path.posix.join(_prefix, filename);
};
}
export interface BunnyAdapterConfig {
/**
* The name of the storage zone
* @example "myzone" - resolves to https://${this.regionPrefix}storage.bunnycdn.com/myzone
*/
zone: string;
/**
* The name of the pull zone, or a full URL to the pull zone
* This is only used for generating URLs when payload access control is disabled.
* @example "myzone" - resolves to https://myzone.b-cdn.net
* @example "https://myzone.b-cdn.net" - resolves to https://myzone.b-cdn.net
* @example new URL("https://myzone.b-cdn.net") - resolves to https://myzone.b-cdn.net
*/
pullZone: string | URL;
/**
* Whether or not to include the storage zone in the URL when generating URLs.
* If a string is provided, it will be used as a prefix to the URL.
* @default false
* @example true - resolves to https://myzone.b-cdn.net/myzone/myfile.jpg
* @example "cdn" - resolves to https://myzone.b-cdn.net/cdn/myfile.jpg
* @example false - resolves to https://myzone.b-cdn.net/myfile.jpg
*/
useZoneInUrl?: boolean | string;
/**
* The BunnyCDN Access Key for the storage zone.
* This is found under as "password" https://dash.bunny.net/storage and FTP & API Access tab. It is not the same as the BunnyCDN API Key.
*/
accessKey: string;
/**
* Storage URL
*/
region?: "default" | "de" | "ny" | "sg" | "la" | "jh" | "br" | "se";
}
const bunnyStorage = ({
zone,
accessKey,
pullZone,
useZoneInUrl = false,
region,
}: BunnyAdapterConfig): Adapter => {
return ({ collection, prefix }) => {
return new BunnyStorageAdapter(
{ zone, accessKey, pullZone, useZoneInUrl, region },
{ collection, prefix },
);
};
};
export { bunnyStorage };
import type {
GenerateURL,
HandleDelete,
StaticHandler,
Adapter,
GeneratedAdapter,
HandleUpload,
TypeWithPrefix,
} from "@payloadcms/plugin-cloud-storage/dist/types";
import { ofetch } from "ofetch";
import { docHasTimestamps } from "payload/types";
import type { FieldBase } from "payload/dist/fields/config/types";
import type { TypeWithID } from "payload/dist/collections/config/types";
import type { FileData } from "payload/dist/uploads/types";
const docHasStream = (doc: unknown): doc is StreamDoc => {
return typeof doc === "object" && "video" in doc;
};
type StreamDoc = TypeWithID &
FileData &
TypeWithPrefix & {
video: {
id: string;
libraryId: number;
collectionId: string;
filename: string;
provider: "bunny";
hls: string;
url: string;
};
};
export class BunnyStreamAdapter implements GeneratedAdapter {
constructor(
private readonly config: BunnyAdapterConfig,
private readonly adapterArgs: Parameters<Adapter>[0]
) {
this.adapterArgs = adapterArgs;
const { collection } = adapterArgs;
const videoFieldExists = collection.fields.some(
(f) => (f as FieldBase).name === "video"
);
if (!videoFieldExists) {
collection.fields.push({
name: "video",
type: "json",
admin: {
hidden: true,
},
});
}
}
handleUpload: HandleUpload = async ({ data, file, collection }) => {
const video = await createVideo({
accessKey: this.config.accessKey,
libraryId: this.config.libraryId,
title: data.filename,
collectionId: this.config.collectionId,
});
// Put video object
const key = video.guid;
if (!key) throw new Error("No guid returned from BunnyCDN");
const res = await fetch(
`https://video.bunnycdn.com/library/${video.videoLibraryId}/videos/${key}`,
{
method: "PUT",
headers: {
AccessKey: this.config.accessKey,
"Content-Type": "application/octet-stream",
},
body: file.buffer,
}
);
if (!res.ok) throw new Error("Failed to upload video");
const response: UploadVideoResponse = await res.json();
if (!response.success) throw new Error(response.message);
data.video = {
provider: "bunny",
id: video.guid,
libraryId: video.videoLibraryId || this.config.libraryId,
collectionId: video.collectionId,
filename: file.filename,
hls: `https://${this.config.zone}.b-cdn.net/${video.guid}/playlist.m3u8`,
url: `https://${this.config.zone}.b-cdn.net/${video.guid}`,
};
return data;
};
handleDelete: HandleDelete = async ({ doc }) => {
const { video } = doc as StreamDoc;
if (!video) return;
const response = await fetcher<DeleteVideoResponse>(
`/library/${video.libraryId || this.config.libraryId}/videos/${video.id}`,
{
method: "DELETE",
headers: {
AccessKey: this.config.accessKey,
},
}
);
// TODO - Remove when confirmed to work
console.log(response);
if (!response.success) throw new Error(response.message);
return;
};
generateURL: GenerateURL = (...args) => {
// Todo - Implement some hacky way to get the pull zone url
const { filename } = args[0];
return `${filename}`;
};
staticHandler: StaticHandler = (req, res, next) => {
// TODO - Implement
return next();
};
static getThumbnail = ({ doc }: { doc: any }) => {
if (!docHasStream(doc)) {
console.warn("Doc is not a stream doc", doc);
return "";
}
const hasTime = docHasTimestamps(doc);
const url = new URL(doc.video.hls);
url.pathname = `${doc.video.id}/thumbnail.jpg`;
if (hasTime) {
url.searchParams.set("v", `${new Date(doc.updatedAt).getTime()}`);
}
return url.toString();
};
}
export interface BunnyAdapterConfig {
/**
* The BunnyCDN Access Key for the storage zone.
* This is found under as "password" https://dash.bunny.net/storage and FTP & API Access tab. It is not the same as the BunnyCDN API Key.
*/
accessKey: string;
/**
* The libraryId to use
*/
libraryId: number;
collectionId?: string;
zone: string;
}
async function createVideo({
accessKey,
libraryId,
title,
collectionId,
}: {
libraryId: number;
title: string;
collectionId?: string;
accessKey: string;
}) {
// await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
// method: "POST",
// });
return await fetcher<CreateVideoResponse>(`/library/${libraryId}/videos`, {
method: "POST",
headers: {
AccessKey: accessKey,
},
body: {
title,
collectionId,
},
});
}
type CreateVideoResponse = {
videoLibraryId: number;
guid: string;
collectionId: string;
};
type UploadVideoResponse = {
success: boolean;
message: string;
statusCode: number;
};
type DeleteVideoResponse = {
success: boolean;
message: string;
statusCode: number;
};
const fetcher = ofetch.create({
baseURL: "https://video.bunnycdn.com",
});
const bunnyStream = (config: BunnyAdapterConfig): Adapter => {
return ({ collection, prefix }) => {
return new BunnyStreamAdapter(config, { collection, prefix });
};
};
export { bunnyStream };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment