Skip to content

Commit 7589a93

Browse files
committed
feat: add github storage adapter plugin
1 parent 3e13dbd commit 7589a93

12 files changed

+832
-923
lines changed

.npmrc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ignore-workspace-root-check=true

package.json

+2-13
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,18 @@
2626
"payload": "^3.0.0-beta.111"
2727
},
2828
"dependencies": {
29-
"@payloadcms/ui": "3.0.0-beta.111",
30-
"react": "19.0.0-rc-3edc000d-20240926",
31-
"react-dom": "19.0.0-rc-3edc000d-20240926"
29+
"@payloadcms/plugin-cloud-storage": "3.0.0-beta.111",
30+
"octokit": "^4.0.2"
3231
},
3332
"devDependencies": {
3433
"@payloadcms/eslint-config": "3.0.0-beta.97",
3534
"@types/node": "22.7.5",
36-
"@types/react": "npm:types-react@19.0.0-rc.1",
37-
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
3835
"copyfiles": "^2.4.1",
3936
"next": "15.0.0-canary.173",
4037
"payload": "3.0.0-beta.111",
41-
"react": "19.0.0-rc-3edc000d-20240926",
42-
"react-dom": "19.0.0-rc-3edc000d-20240926",
4338
"rimraf": "^6.0.1",
4439
"typescript": "5.5.3"
4540
},
46-
"pnpm": {
47-
"overrides": {
48-
"@types/react": "npm:types-react@19.0.0-rc.1",
49-
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
50-
}
51-
},
5241
"publishConfig": {
5342
"main": "./dist/index.js",
5443
"registry": "https://registry.npmjs.org/",

pnpm-lock.yaml

+449-899
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
packages:
22
- './'
3-
- './dev'
3+
# - './dev'

src/generateURL.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { GenerateURL } from '@payloadcms/plugin-cloud-storage/types'
2+
3+
import path from 'node:path'
4+
5+
interface Args {
6+
owner: string
7+
repo: string
8+
branch: string
9+
}
10+
11+
export const getGenerateURL = ({ branch, owner, repo }: Args): GenerateURL => {
12+
return ({ filename, prefix = '' }) => {
13+
return `https://raw.githubusercontent.com/${owner}/${repo}/refs/heads/${branch}/${path.posix.join(prefix, filename)}`
14+
}
15+
}

src/handleDelete.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { HandleDelete } from '@payloadcms/plugin-cloud-storage/types'
2+
import type { GithubAuthor } from './types'
3+
4+
import { Octokit } from 'octokit'
5+
import path from 'node:path'
6+
7+
import { getFileSHA } from './utilities'
8+
9+
interface Args {
10+
owner: string
11+
repo: string
12+
branch: string
13+
committer?: GithubAuthor
14+
author?: GithubAuthor
15+
getStorageClient: () => Octokit
16+
}
17+
18+
export const getHandleDelete = ({
19+
getStorageClient,
20+
committer,
21+
author,
22+
owner,
23+
repo,
24+
branch,
25+
}: Args): HandleDelete => {
26+
return async ({ doc: { prefix = '' }, filename }) => {
27+
const sha = await getFileSHA(getStorageClient, owner, repo, branch, prefix, filename)
28+
29+
await getStorageClient().rest.repos.deleteFile({
30+
committer,
31+
author,
32+
owner,
33+
repo,
34+
branch,
35+
sha,
36+
path: path.posix.join(prefix, filename),
37+
message: `:x: Delete "${filename}"`,
38+
})
39+
}
40+
}

src/handleUpload.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { HandleUpload } from '@payloadcms/plugin-cloud-storage/types'
2+
import type { GithubAuthor } from './types'
3+
4+
import { Octokit } from 'octokit'
5+
import path from 'node:path'
6+
7+
import { getFileSHA } from './utilities'
8+
9+
interface Args {
10+
owner: string
11+
repo: string
12+
branch: string
13+
prefix?: string
14+
committer?: GithubAuthor
15+
author?: GithubAuthor
16+
getStorageClient: () => Octokit
17+
}
18+
19+
export const getHandleUpload = ({
20+
getStorageClient,
21+
committer,
22+
author,
23+
owner,
24+
repo,
25+
branch,
26+
prefix = '',
27+
}: Args): HandleUpload => {
28+
return async ({ data, file }) => {
29+
const sha = await getFileSHA(getStorageClient, owner, repo, branch, prefix, file.filename)
30+
31+
await getStorageClient().rest.repos.createOrUpdateFileContents({
32+
committer,
33+
author,
34+
owner,
35+
repo,
36+
branch,
37+
sha,
38+
path: path.posix.join(data.prefix || prefix, file.filename),
39+
content: file.buffer.toString('base64'),
40+
message: data.commitMessage || `:arrow_up_small: Upload "${file.filename}"`,
41+
})
42+
}
43+
}

src/index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { myPlugin } from './plugin.js'
2-
export type { MyPluginOptions } from './types.js'
1+
export { githubStorage } from './plugin.js'
2+
export type { GithubStorageOptions } from './types.js'

src/plugin.ts

+105-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,109 @@
1-
import type { Config } from 'payload'
1+
import type { Config, Plugin } from 'payload'
2+
import type {
3+
Adapter,
4+
CollectionOptions,
5+
GeneratedAdapter,
6+
PluginOptions as GithubStoragePluginOptions,
7+
} from '@payloadcms/plugin-cloud-storage/types'
8+
import type { GithubStorageOptions } from './types.js'
29

3-
import type { MyPluginOptions } from './types.js'
10+
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
11+
import { Octokit } from 'octokit'
412

5-
export const myPlugin =
6-
(_: MyPluginOptions) =>
13+
import { getGenerateURL } from './generateURL.js'
14+
import { getHandleDelete } from './handleDelete.js'
15+
import { getHandleUpload } from './handleUpload.js'
16+
import { getStaticHandler } from './staticHandler.js'
17+
18+
type GithubStoragePlugin = (githubStorageArgs: GithubStorageOptions) => Plugin
19+
20+
export const githubStorage: GithubStoragePlugin =
21+
(githubStorageOptions: GithubStorageOptions) =>
722
(incomingConfig: Config): Config => {
8-
const config = { ...incomingConfig }
9-
return config
23+
if (githubStorageOptions.enabled === false) {
24+
return incomingConfig
25+
}
26+
27+
const adapter = githubStorageInternal(githubStorageOptions)
28+
29+
// Add adapter to each collection option object
30+
const collectionsWithAdapter: GithubStoragePluginOptions['collections'] = Object.entries(
31+
githubStorageOptions.collections,
32+
).reduce(
33+
(acc, [slug, collOptions]) => ({
34+
...acc,
35+
[slug]: {
36+
...(collOptions === true ? {} : collOptions),
37+
adapter,
38+
},
39+
}),
40+
{} as Record<string, CollectionOptions>,
41+
)
42+
43+
// Set disableLocalStorage: true for collections specified in the plugin options
44+
const config = {
45+
...incomingConfig,
46+
collections: (incomingConfig.collections || []).map((collection) => {
47+
if (!collectionsWithAdapter[collection.slug]) {
48+
return collection
49+
}
50+
51+
return {
52+
...collection,
53+
upload: {
54+
...(typeof collection.upload === 'object' ? collection.upload : {}),
55+
disableLocalStorage: true,
56+
},
57+
}
58+
}),
59+
}
60+
61+
return cloudStoragePlugin({
62+
collections: collectionsWithAdapter,
63+
})(config)
64+
}
65+
66+
function githubStorageInternal({
67+
owner,
68+
repo,
69+
options,
70+
branch = 'main',
71+
}: GithubStorageOptions): Adapter {
72+
return ({ collection, prefix }): GeneratedAdapter => {
73+
let storageClient: Octokit | null = null
74+
75+
const getStorageClient = (): Octokit => {
76+
if (storageClient) {
77+
return storageClient
78+
}
79+
80+
storageClient = new Octokit(options)
81+
return storageClient
82+
}
83+
84+
return {
85+
name: 'github',
86+
generateURL: getGenerateURL({ branch, owner, repo }),
87+
handleDelete: getHandleDelete({
88+
getStorageClient,
89+
owner,
90+
repo,
91+
branch,
92+
}),
93+
handleUpload: getHandleUpload({
94+
getStorageClient,
95+
owner,
96+
repo,
97+
branch,
98+
prefix,
99+
}),
100+
staticHandler: getStaticHandler({
101+
owner,
102+
repo,
103+
branch,
104+
collection,
105+
getStorageClient,
106+
}),
107+
}
10108
}
109+
}

src/staticHandler.ts

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { CollectionConfig } from 'payload'
2+
import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types'
3+
4+
import { Octokit, RequestError } from 'octokit'
5+
import path from 'node:path'
6+
7+
import { getFileProperties } from './utilities'
8+
9+
interface Args {
10+
owner: string
11+
repo: string
12+
branch: string
13+
collection: CollectionConfig
14+
getStorageClient: () => Octokit
15+
}
16+
17+
export const getStaticHandler = ({
18+
collection,
19+
owner,
20+
repo,
21+
branch,
22+
getStorageClient,
23+
}: Args): StaticHandler => {
24+
return async (req, { params: { filename } }) => {
25+
try {
26+
const { prefix, mimeType } = await getFileProperties({ collection, filename, req })
27+
const { data, headers } = await getStorageClient().rest.repos.getContent({
28+
owner,
29+
repo,
30+
ref: branch,
31+
path: path.posix.join(prefix, filename),
32+
})
33+
34+
const bodyBuffer = 'content' in data ? Buffer.from(data.content, 'base64') : ''
35+
36+
return new Response(bodyBuffer, {
37+
headers: new Headers({
38+
'Content-Type': mimeType || 'application/octet-stream',
39+
...('size' in data && {
40+
'Content-Length': String(data.size),
41+
}),
42+
...('etag' in headers && {
43+
ETag: headers.etag,
44+
}),
45+
}),
46+
status: 200,
47+
})
48+
} catch (err: unknown) {
49+
if (err instanceof RequestError && err.status === 404) {
50+
return new Response(null, { status: 404, statusText: 'Not Found' })
51+
}
52+
53+
req.payload.logger.error({ err, msg: 'Unexpected error in staticHandler' })
54+
return new Response('Internal Server Error', { status: 500 })
55+
}
56+
}
57+
}

src/types.ts

+46-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,48 @@
1-
export type MyPluginOptions = {
2-
debug?: boolean
1+
import type { CollectionOptions } from '@payloadcms/plugin-cloud-storage/types'
2+
import { Octokit } from 'octokit'
3+
4+
type OctokitOptions = ConstructorParameters<typeof Octokit>[0]
5+
6+
export interface GithubStorageOptions {
7+
/**
8+
* The name of the repository owner (GitHub username or organization).
9+
*/
10+
owner: string
11+
12+
/**
13+
* The repository name.
14+
*/
15+
repo: string
16+
17+
/**
18+
* Which branch to upload/read files.
19+
*
20+
* Default: "main"
21+
*/
22+
branch?: string
23+
24+
/**
25+
* Collection options to apply the GitHub adapter to.
26+
*/
27+
collections: Record<string, Omit<CollectionOptions, 'adapter'> | true>
28+
29+
/**
30+
* Whether or not to enable the plugin.
31+
*
32+
* Default: true
33+
*/
334
enabled?: boolean
35+
36+
/**
37+
* Octokit client configuration.
38+
*
39+
* @see https://github.com/octokit/octokit.js
40+
*/
41+
options: OctokitOptions
42+
}
43+
44+
export interface GithubAuthor {
45+
name: string
46+
email: string
47+
date?: string
448
}

0 commit comments

Comments
 (0)