decap-cms-backend-github 2.15.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +817 -0
- package/LICENSE +22 -0
- package/README.md +17 -0
- package/dist/decap-cms-backend-github.js +312 -0
- package/dist/decap-cms-backend-github.js.LICENSE.txt +23 -0
- package/dist/decap-cms-backend-github.js.map +1 -0
- package/dist/esm/API.js +1217 -0
- package/dist/esm/AuthenticationPage.js +192 -0
- package/dist/esm/GraphQLAPI.js +807 -0
- package/dist/esm/fragmentTypes.js +947 -0
- package/dist/esm/fragments.js +98 -0
- package/dist/esm/implementation.js +585 -0
- package/dist/esm/index.js +34 -0
- package/dist/esm/mutations.js +117 -0
- package/dist/esm/queries.js +212 -0
- package/dist/esm/types/semaphore.d.js +1 -0
- package/package.json +44 -0
- package/scripts/createFragmentTypes.js +48 -0
- package/src/API.ts +1468 -0
- package/src/AuthenticationPage.js +151 -0
- package/src/GraphQLAPI.ts +709 -0
- package/src/__tests__/API.spec.js +833 -0
- package/src/__tests__/GraphQLAPI.spec.js +69 -0
- package/src/__tests__/implementation.spec.js +361 -0
- package/src/fragmentTypes.js +1 -0
- package/src/fragments.ts +92 -0
- package/src/implementation.tsx +672 -0
- package/src/index.ts +10 -0
- package/src/mutations.ts +110 -0
- package/src/queries.ts +213 -0
- package/src/types/semaphore.d.ts +5 -0
- package/webpack.config.js +3 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import semaphore from 'semaphore';
|
|
3
|
+
import trimStart from 'lodash/trimStart';
|
|
4
|
+
import { stripIndent } from 'common-tags';
|
|
5
|
+
import {
|
|
6
|
+
CURSOR_COMPATIBILITY_SYMBOL,
|
|
7
|
+
Cursor,
|
|
8
|
+
asyncLock,
|
|
9
|
+
basename,
|
|
10
|
+
getBlobSHA,
|
|
11
|
+
entriesByFolder,
|
|
12
|
+
entriesByFiles,
|
|
13
|
+
unpublishedEntries,
|
|
14
|
+
getMediaDisplayURL,
|
|
15
|
+
getMediaAsBlob,
|
|
16
|
+
filterByExtension,
|
|
17
|
+
getPreviewStatus,
|
|
18
|
+
runWithLock,
|
|
19
|
+
blobToFileObj,
|
|
20
|
+
contentKeyFromBranch,
|
|
21
|
+
unsentRequest,
|
|
22
|
+
branchFromContentKey,
|
|
23
|
+
} from 'decap-cms-lib-util';
|
|
24
|
+
|
|
25
|
+
import AuthenticationPage from './AuthenticationPage';
|
|
26
|
+
import API, { API_NAME } from './API';
|
|
27
|
+
import GraphQLAPI from './GraphQLAPI';
|
|
28
|
+
|
|
29
|
+
import type { Octokit } from '@octokit/rest';
|
|
30
|
+
import type {
|
|
31
|
+
AsyncLock,
|
|
32
|
+
Implementation,
|
|
33
|
+
AssetProxy,
|
|
34
|
+
PersistOptions,
|
|
35
|
+
DisplayURL,
|
|
36
|
+
User,
|
|
37
|
+
Credentials,
|
|
38
|
+
Config,
|
|
39
|
+
ImplementationFile,
|
|
40
|
+
UnpublishedEntryMediaFile,
|
|
41
|
+
Entry,
|
|
42
|
+
} from 'decap-cms-lib-util';
|
|
43
|
+
import type { Semaphore } from 'semaphore';
|
|
44
|
+
|
|
45
|
+
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
|
|
46
|
+
|
|
47
|
+
const MAX_CONCURRENT_DOWNLOADS = 10;
|
|
48
|
+
|
|
49
|
+
type ApiFile = { id: string; type: string; name: string; path: string; size: number };
|
|
50
|
+
|
|
51
|
+
const { fetchWithTimeout: fetch } = unsentRequest;
|
|
52
|
+
|
|
53
|
+
const STATUS_PAGE = 'https://www.githubstatus.com';
|
|
54
|
+
const GITHUB_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
|
|
55
|
+
const GITHUB_OPERATIONAL_UNITS = ['API Requests', 'Issues, Pull Requests, Projects'];
|
|
56
|
+
type GitHubStatusComponent = {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
status: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default class GitHub implements Implementation {
|
|
63
|
+
lock: AsyncLock;
|
|
64
|
+
api: API | null;
|
|
65
|
+
options: {
|
|
66
|
+
proxied: boolean;
|
|
67
|
+
API: API | null;
|
|
68
|
+
useWorkflow?: boolean;
|
|
69
|
+
initialWorkflowStatus: string;
|
|
70
|
+
};
|
|
71
|
+
originRepo: string;
|
|
72
|
+
repo?: string;
|
|
73
|
+
openAuthoringEnabled: boolean;
|
|
74
|
+
useOpenAuthoring?: boolean;
|
|
75
|
+
alwaysForkEnabled: boolean;
|
|
76
|
+
branch: string;
|
|
77
|
+
apiRoot: string;
|
|
78
|
+
mediaFolder: string;
|
|
79
|
+
previewContext: string;
|
|
80
|
+
token: string | null;
|
|
81
|
+
squashMerges: boolean;
|
|
82
|
+
cmsLabelPrefix: string;
|
|
83
|
+
useGraphql: boolean;
|
|
84
|
+
_currentUserPromise?: Promise<GitHubUser>;
|
|
85
|
+
_userIsOriginMaintainerPromises?: {
|
|
86
|
+
[key: string]: Promise<boolean>;
|
|
87
|
+
};
|
|
88
|
+
_mediaDisplayURLSem?: Semaphore;
|
|
89
|
+
|
|
90
|
+
constructor(config: Config, options = {}) {
|
|
91
|
+
this.options = {
|
|
92
|
+
proxied: false,
|
|
93
|
+
API: null,
|
|
94
|
+
initialWorkflowStatus: '',
|
|
95
|
+
...options,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
!this.options.proxied &&
|
|
100
|
+
(config.backend.repo === null || config.backend.repo === undefined)
|
|
101
|
+
) {
|
|
102
|
+
throw new Error('The GitHub backend needs a "repo" in the backend configuration.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.api = this.options.API || null;
|
|
106
|
+
|
|
107
|
+
this.openAuthoringEnabled = config.backend.open_authoring || false;
|
|
108
|
+
if (this.openAuthoringEnabled) {
|
|
109
|
+
if (!this.options.useWorkflow) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'backend.open_authoring is true but publish_mode is not set to editorial_workflow.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
this.originRepo = config.backend.repo || '';
|
|
115
|
+
} else {
|
|
116
|
+
this.repo = this.originRepo = config.backend.repo || '';
|
|
117
|
+
}
|
|
118
|
+
this.alwaysForkEnabled = config.backend.always_fork || false;
|
|
119
|
+
this.branch = config.backend.branch?.trim() || 'master';
|
|
120
|
+
this.apiRoot = config.backend.api_root || 'https://api.github.com';
|
|
121
|
+
this.token = '';
|
|
122
|
+
this.squashMerges = config.backend.squash_merges || false;
|
|
123
|
+
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
|
|
124
|
+
this.useGraphql = config.backend.use_graphql || false;
|
|
125
|
+
this.mediaFolder = config.media_folder;
|
|
126
|
+
this.previewContext = config.backend.preview_context || '';
|
|
127
|
+
this.lock = asyncLock();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
isGitBackend() {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async status() {
|
|
135
|
+
const api = await fetch(GITHUB_STATUS_ENDPOINT)
|
|
136
|
+
.then(res => res.json())
|
|
137
|
+
.then(res => {
|
|
138
|
+
return res['components']
|
|
139
|
+
.filter((statusComponent: GitHubStatusComponent) =>
|
|
140
|
+
GITHUB_OPERATIONAL_UNITS.includes(statusComponent.name),
|
|
141
|
+
)
|
|
142
|
+
.every(
|
|
143
|
+
(statusComponent: GitHubStatusComponent) => statusComponent.status === 'operational',
|
|
144
|
+
);
|
|
145
|
+
})
|
|
146
|
+
.catch(e => {
|
|
147
|
+
console.warn('Failed getting GitHub status', e);
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let auth = false;
|
|
152
|
+
// no need to check auth if api is down
|
|
153
|
+
if (api) {
|
|
154
|
+
auth =
|
|
155
|
+
(await this.api
|
|
156
|
+
?.getUser()
|
|
157
|
+
.then(user => !!user)
|
|
158
|
+
.catch(e => {
|
|
159
|
+
console.warn('Failed getting GitHub user', e);
|
|
160
|
+
return false;
|
|
161
|
+
})) || false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
authComponent() {
|
|
168
|
+
const wrappedAuthenticationPage = (props: Record<string, unknown>) => (
|
|
169
|
+
<AuthenticationPage {...props} backend={this} />
|
|
170
|
+
);
|
|
171
|
+
wrappedAuthenticationPage.displayName = 'AuthenticationPage';
|
|
172
|
+
return wrappedAuthenticationPage;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
restoreUser(user: User) {
|
|
176
|
+
return this.openAuthoringEnabled
|
|
177
|
+
? this.authenticateWithFork({ userData: user, getPermissionToFork: () => true }).then(() =>
|
|
178
|
+
this.authenticate(user),
|
|
179
|
+
)
|
|
180
|
+
: this.authenticate(user);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async pollUntilForkExists({ repo, token }: { repo: string; token: string }) {
|
|
184
|
+
const pollDelay = 250; // milliseconds
|
|
185
|
+
let repoExists = false;
|
|
186
|
+
while (!repoExists) {
|
|
187
|
+
repoExists = await fetch(`${this.apiRoot}/repos/${repo}`, {
|
|
188
|
+
headers: { Authorization: `token ${token}` },
|
|
189
|
+
})
|
|
190
|
+
.then(() => true)
|
|
191
|
+
.catch(err => {
|
|
192
|
+
if (err && err.status === 404) {
|
|
193
|
+
console.log('This 404 was expected and handled appropriately.');
|
|
194
|
+
return false;
|
|
195
|
+
} else {
|
|
196
|
+
return Promise.reject(err);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
// wait between polls
|
|
200
|
+
if (!repoExists) {
|
|
201
|
+
await new Promise(resolve => setTimeout(resolve, pollDelay));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return Promise.resolve();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async currentUser({ token }: { token: string }) {
|
|
208
|
+
if (!this._currentUserPromise) {
|
|
209
|
+
this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
|
|
210
|
+
headers: {
|
|
211
|
+
Authorization: `token ${token}`,
|
|
212
|
+
},
|
|
213
|
+
}).then(res => res.json());
|
|
214
|
+
}
|
|
215
|
+
return this._currentUserPromise;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async userIsOriginMaintainer({
|
|
219
|
+
username: usernameArg,
|
|
220
|
+
token,
|
|
221
|
+
}: {
|
|
222
|
+
username?: string;
|
|
223
|
+
token: string;
|
|
224
|
+
}) {
|
|
225
|
+
const username = usernameArg || (await this.currentUser({ token })).login;
|
|
226
|
+
this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
|
|
227
|
+
if (!this._userIsOriginMaintainerPromises[username]) {
|
|
228
|
+
this._userIsOriginMaintainerPromises[username] = fetch(
|
|
229
|
+
`${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`,
|
|
230
|
+
{
|
|
231
|
+
headers: {
|
|
232
|
+
Authorization: `token ${token}`,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
.then(res => res.json())
|
|
237
|
+
.then(({ permission }) => permission === 'admin' || permission === 'write');
|
|
238
|
+
}
|
|
239
|
+
return this._userIsOriginMaintainerPromises[username];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async forkExists({ token }: { token: string }) {
|
|
243
|
+
try {
|
|
244
|
+
const currentUser = await this.currentUser({ token });
|
|
245
|
+
const repoName = this.originRepo.split('/')[1];
|
|
246
|
+
const repo = await fetch(`${this.apiRoot}/repos/${currentUser.login}/${repoName}`, {
|
|
247
|
+
method: 'GET',
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `token ${token}`,
|
|
250
|
+
},
|
|
251
|
+
}).then(res => res.json());
|
|
252
|
+
|
|
253
|
+
// https://developer.github.com/v3/repos/#get
|
|
254
|
+
// The parent and source objects are present when the repository is a fork.
|
|
255
|
+
// parent is the repository this repository was forked from, source is the ultimate source for the network.
|
|
256
|
+
const forkExists =
|
|
257
|
+
repo.fork === true &&
|
|
258
|
+
repo.parent &&
|
|
259
|
+
repo.parent.full_name.toLowerCase() === this.originRepo.toLowerCase();
|
|
260
|
+
return forkExists;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async authenticateWithFork({
|
|
267
|
+
userData,
|
|
268
|
+
getPermissionToFork,
|
|
269
|
+
}: {
|
|
270
|
+
userData: User;
|
|
271
|
+
getPermissionToFork: () => Promise<boolean> | boolean;
|
|
272
|
+
}) {
|
|
273
|
+
if (!this.openAuthoringEnabled) {
|
|
274
|
+
throw new Error('Cannot authenticate with fork; Open Authoring is turned off.');
|
|
275
|
+
}
|
|
276
|
+
const token = userData.token as string;
|
|
277
|
+
|
|
278
|
+
// Origin maintainers should be able to use the CMS normally. If alwaysFork
|
|
279
|
+
// is enabled we always fork (and avoid the origin maintainer check)
|
|
280
|
+
if (!this.alwaysForkEnabled && (await this.userIsOriginMaintainer({ token }))) {
|
|
281
|
+
this.repo = this.originRepo;
|
|
282
|
+
this.useOpenAuthoring = false;
|
|
283
|
+
return Promise.resolve();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!(await this.forkExists({ token }))) {
|
|
287
|
+
await getPermissionToFork();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const fork = await fetch(`${this.apiRoot}/repos/${this.originRepo}/forks`, {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
headers: {
|
|
293
|
+
Authorization: `token ${token}`,
|
|
294
|
+
},
|
|
295
|
+
}).then(res => res.json());
|
|
296
|
+
this.useOpenAuthoring = true;
|
|
297
|
+
this.repo = fork.full_name;
|
|
298
|
+
return this.pollUntilForkExists({ repo: fork.full_name, token });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async authenticate(state: Credentials) {
|
|
302
|
+
this.token = state.token as string;
|
|
303
|
+
const apiCtor = this.useGraphql ? GraphQLAPI : API;
|
|
304
|
+
this.api = new apiCtor({
|
|
305
|
+
token: this.token,
|
|
306
|
+
branch: this.branch,
|
|
307
|
+
repo: this.repo,
|
|
308
|
+
originRepo: this.originRepo,
|
|
309
|
+
apiRoot: this.apiRoot,
|
|
310
|
+
squashMerges: this.squashMerges,
|
|
311
|
+
cmsLabelPrefix: this.cmsLabelPrefix,
|
|
312
|
+
useOpenAuthoring: this.useOpenAuthoring,
|
|
313
|
+
initialWorkflowStatus: this.options.initialWorkflowStatus,
|
|
314
|
+
});
|
|
315
|
+
const user = await this.api!.user();
|
|
316
|
+
const isCollab = await this.api!.hasWriteAccess().catch(error => {
|
|
317
|
+
error.message = stripIndent`
|
|
318
|
+
Repo "${this.repo}" not found.
|
|
319
|
+
|
|
320
|
+
Please ensure the repo information is spelled correctly.
|
|
321
|
+
|
|
322
|
+
If the repo is private, make sure you're logged into a GitHub account with access.
|
|
323
|
+
|
|
324
|
+
If your repo is under an organization, ensure the organization has granted access to Decap CMS.
|
|
325
|
+
`;
|
|
326
|
+
throw error;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Unauthorized user
|
|
330
|
+
if (!isCollab) {
|
|
331
|
+
throw new Error('Your GitHub user account does not have access to this repo.');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Authorized user
|
|
335
|
+
return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
logout() {
|
|
339
|
+
this.token = null;
|
|
340
|
+
if (this.api && this.api.reset && typeof this.api.reset === 'function') {
|
|
341
|
+
return this.api.reset();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
getToken() {
|
|
346
|
+
return Promise.resolve(this.token);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
getCursorAndFiles = (files: ApiFile[], page: number) => {
|
|
350
|
+
const pageSize = 20;
|
|
351
|
+
const count = files.length;
|
|
352
|
+
const pageCount = Math.ceil(files.length / pageSize);
|
|
353
|
+
|
|
354
|
+
const actions = [] as string[];
|
|
355
|
+
if (page > 1) {
|
|
356
|
+
actions.push('prev');
|
|
357
|
+
actions.push('first');
|
|
358
|
+
}
|
|
359
|
+
if (page < pageCount) {
|
|
360
|
+
actions.push('next');
|
|
361
|
+
actions.push('last');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const cursor = Cursor.create({
|
|
365
|
+
actions,
|
|
366
|
+
meta: { page, count, pageSize, pageCount },
|
|
367
|
+
data: { files },
|
|
368
|
+
});
|
|
369
|
+
const pageFiles = files.slice((page - 1) * pageSize, page * pageSize);
|
|
370
|
+
return { cursor, files: pageFiles };
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
async entriesByFolder(folder: string, extension: string, depth: number) {
|
|
374
|
+
const repoURL = this.api!.originRepoURL;
|
|
375
|
+
|
|
376
|
+
let cursor: Cursor;
|
|
377
|
+
|
|
378
|
+
const listFiles = () =>
|
|
379
|
+
this.api!.listFiles(folder, {
|
|
380
|
+
repoURL,
|
|
381
|
+
depth,
|
|
382
|
+
}).then(files => {
|
|
383
|
+
const filtered = files.filter(file => filterByExtension(file, extension));
|
|
384
|
+
const result = this.getCursorAndFiles(filtered, 1);
|
|
385
|
+
cursor = result.cursor;
|
|
386
|
+
return result.files;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const readFile = (path: string, id: string | null | undefined) =>
|
|
390
|
+
this.api!.readFile(path, id, { repoURL }) as Promise<string>;
|
|
391
|
+
|
|
392
|
+
const files = await entriesByFolder(
|
|
393
|
+
listFiles,
|
|
394
|
+
readFile,
|
|
395
|
+
this.api!.readFileMetadata.bind(this.api),
|
|
396
|
+
API_NAME,
|
|
397
|
+
);
|
|
398
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
399
|
+
// @ts-ignore
|
|
400
|
+
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
|
401
|
+
return files;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async allEntriesByFolder(folder: string, extension: string, depth: number) {
|
|
405
|
+
const repoURL = this.api!.originRepoURL;
|
|
406
|
+
|
|
407
|
+
const listFiles = () =>
|
|
408
|
+
this.api!.listFiles(folder, {
|
|
409
|
+
repoURL,
|
|
410
|
+
depth,
|
|
411
|
+
}).then(files => files.filter(file => filterByExtension(file, extension)));
|
|
412
|
+
|
|
413
|
+
const readFile = (path: string, id: string | null | undefined) => {
|
|
414
|
+
return this.api!.readFile(path, id, { repoURL }) as Promise<string>;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const files = await entriesByFolder(
|
|
418
|
+
listFiles,
|
|
419
|
+
readFile,
|
|
420
|
+
this.api!.readFileMetadata.bind(this.api),
|
|
421
|
+
API_NAME,
|
|
422
|
+
);
|
|
423
|
+
return files;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
entriesByFiles(files: ImplementationFile[]) {
|
|
427
|
+
const repoURL = this.useOpenAuthoring ? this.api!.originRepoURL : this.api!.repoURL;
|
|
428
|
+
|
|
429
|
+
const readFile = (path: string, id: string | null | undefined) =>
|
|
430
|
+
this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise<string>;
|
|
431
|
+
|
|
432
|
+
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Fetches a single entry.
|
|
436
|
+
getEntry(path: string) {
|
|
437
|
+
const repoURL = this.api!.originRepoURL;
|
|
438
|
+
return this.api!.readFile(path, null, { repoURL })
|
|
439
|
+
.then(data => ({
|
|
440
|
+
file: { path, id: null },
|
|
441
|
+
data: data as string,
|
|
442
|
+
}))
|
|
443
|
+
.catch(() => ({ file: { path, id: null }, data: '' }));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
getMedia(mediaFolder = this.mediaFolder) {
|
|
447
|
+
return this.api!.listFiles(mediaFolder).then(files =>
|
|
448
|
+
files.map(({ id, name, size, path }) => {
|
|
449
|
+
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
|
|
450
|
+
// for private repositories
|
|
451
|
+
return { id, name, size, displayURL: { id, path }, path };
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async getMediaFile(path: string) {
|
|
457
|
+
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
|
|
458
|
+
|
|
459
|
+
const name = basename(path);
|
|
460
|
+
const fileObj = blobToFileObj(name, blob);
|
|
461
|
+
const url = URL.createObjectURL(fileObj);
|
|
462
|
+
const id = await getBlobSHA(blob);
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
id,
|
|
466
|
+
displayURL: url,
|
|
467
|
+
path,
|
|
468
|
+
name,
|
|
469
|
+
size: fileObj.size,
|
|
470
|
+
file: fileObj,
|
|
471
|
+
url,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
getMediaDisplayURL(displayURL: DisplayURL) {
|
|
476
|
+
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
|
|
477
|
+
return getMediaDisplayURL(
|
|
478
|
+
displayURL,
|
|
479
|
+
this.api!.readFile.bind(this.api!),
|
|
480
|
+
this._mediaDisplayURLSem,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
persistEntry(entry: Entry, options: PersistOptions) {
|
|
485
|
+
// persistEntry is a transactional operation
|
|
486
|
+
return runWithLock(
|
|
487
|
+
this.lock,
|
|
488
|
+
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
|
|
489
|
+
'Failed to acquire persist entry lock',
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
|
494
|
+
try {
|
|
495
|
+
await this.api!.persistFiles([], [mediaFile], options);
|
|
496
|
+
const { sha, path, fileObj } = mediaFile as AssetProxy & { sha: string };
|
|
497
|
+
const displayURL = fileObj ? URL.createObjectURL(fileObj) : '';
|
|
498
|
+
return {
|
|
499
|
+
id: sha,
|
|
500
|
+
name: fileObj!.name,
|
|
501
|
+
size: fileObj!.size,
|
|
502
|
+
displayURL,
|
|
503
|
+
path: trimStart(path, '/'),
|
|
504
|
+
};
|
|
505
|
+
} catch (error) {
|
|
506
|
+
console.error(error);
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
deleteFiles(paths: string[], commitMessage: string) {
|
|
512
|
+
return this.api!.deleteFiles(paths, commitMessage);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async traverseCursor(cursor: Cursor, action: string) {
|
|
516
|
+
const meta = cursor.meta!;
|
|
517
|
+
const files = cursor.data!.get('files')!.toJS() as ApiFile[];
|
|
518
|
+
|
|
519
|
+
let result: { cursor: Cursor; files: ApiFile[] };
|
|
520
|
+
switch (action) {
|
|
521
|
+
case 'first': {
|
|
522
|
+
result = this.getCursorAndFiles(files, 1);
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case 'last': {
|
|
526
|
+
result = this.getCursorAndFiles(files, meta.get('pageCount'));
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case 'next': {
|
|
530
|
+
result = this.getCursorAndFiles(files, meta.get('page') + 1);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
case 'prev': {
|
|
534
|
+
result = this.getCursorAndFiles(files, meta.get('page') - 1);
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
default: {
|
|
538
|
+
result = this.getCursorAndFiles(files, 1);
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const readFile = (path: string, id: string | null | undefined) =>
|
|
544
|
+
this.api!.readFile(path, id, { repoURL: this.api!.originRepoURL }).catch(
|
|
545
|
+
() => '',
|
|
546
|
+
) as Promise<string>;
|
|
547
|
+
|
|
548
|
+
const entries = await entriesByFiles(
|
|
549
|
+
result.files,
|
|
550
|
+
readFile,
|
|
551
|
+
this.api!.readFileMetadata.bind(this.api),
|
|
552
|
+
API_NAME,
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
entries,
|
|
557
|
+
cursor: result.cursor,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
|
|
562
|
+
const readFile = (
|
|
563
|
+
path: string,
|
|
564
|
+
id: string | null | undefined,
|
|
565
|
+
{ parseText }: { parseText: boolean },
|
|
566
|
+
) => this.api!.readFile(path, id, { branch, parseText });
|
|
567
|
+
|
|
568
|
+
const blob = await getMediaAsBlob(file.path, file.id, readFile);
|
|
569
|
+
const name = basename(file.path);
|
|
570
|
+
const fileObj = blobToFileObj(name, blob);
|
|
571
|
+
return {
|
|
572
|
+
id: file.id,
|
|
573
|
+
displayURL: URL.createObjectURL(fileObj),
|
|
574
|
+
path: file.path,
|
|
575
|
+
name,
|
|
576
|
+
size: fileObj.size,
|
|
577
|
+
file: fileObj,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async unpublishedEntries() {
|
|
582
|
+
const listEntriesKeys = () =>
|
|
583
|
+
this.api!.listUnpublishedBranches().then(branches =>
|
|
584
|
+
branches.map(branch => contentKeyFromBranch(branch)),
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
const ids = await unpublishedEntries(listEntriesKeys);
|
|
588
|
+
return ids;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async unpublishedEntry({
|
|
592
|
+
id,
|
|
593
|
+
collection,
|
|
594
|
+
slug,
|
|
595
|
+
}: {
|
|
596
|
+
id?: string;
|
|
597
|
+
collection?: string;
|
|
598
|
+
slug?: string;
|
|
599
|
+
}) {
|
|
600
|
+
if (id) {
|
|
601
|
+
const data = await this.api!.retrieveUnpublishedEntryData(id);
|
|
602
|
+
return data;
|
|
603
|
+
} else if (collection && slug) {
|
|
604
|
+
const entryId = this.api!.generateContentKey(collection, slug);
|
|
605
|
+
const data = await this.api!.retrieveUnpublishedEntryData(entryId);
|
|
606
|
+
return data;
|
|
607
|
+
} else {
|
|
608
|
+
throw new Error('Missing unpublished entry id or collection and slug');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
getBranch(collection: string, slug: string) {
|
|
613
|
+
const contentKey = this.api!.generateContentKey(collection, slug);
|
|
614
|
+
const branch = branchFromContentKey(contentKey);
|
|
615
|
+
return branch;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
|
|
619
|
+
const branch = this.getBranch(collection, slug);
|
|
620
|
+
const data = (await this.api!.readFile(path, id, { branch })) as string;
|
|
621
|
+
return data;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
|
|
625
|
+
const branch = this.getBranch(collection, slug);
|
|
626
|
+
const mediaFile = await this.loadMediaFile(branch, { path, id });
|
|
627
|
+
return mediaFile;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async getDeployPreview(collection: string, slug: string) {
|
|
631
|
+
try {
|
|
632
|
+
const statuses = await this.api!.getStatuses(collection, slug);
|
|
633
|
+
const deployStatus = getPreviewStatus(statuses, this.previewContext);
|
|
634
|
+
|
|
635
|
+
if (deployStatus) {
|
|
636
|
+
const { target_url: url, state } = deployStatus;
|
|
637
|
+
return { url, status: state };
|
|
638
|
+
} else {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
} catch (e) {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
|
|
647
|
+
// updateUnpublishedEntryStatus is a transactional operation
|
|
648
|
+
return runWithLock(
|
|
649
|
+
this.lock,
|
|
650
|
+
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
|
|
651
|
+
'Failed to acquire update entry status lock',
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
deleteUnpublishedEntry(collection: string, slug: string) {
|
|
656
|
+
// deleteUnpublishedEntry is a transactional operation
|
|
657
|
+
return runWithLock(
|
|
658
|
+
this.lock,
|
|
659
|
+
() => this.api!.deleteUnpublishedEntry(collection, slug),
|
|
660
|
+
'Failed to acquire delete entry lock',
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
publishUnpublishedEntry(collection: string, slug: string) {
|
|
665
|
+
// publishUnpublishedEntry is a transactional operation
|
|
666
|
+
return runWithLock(
|
|
667
|
+
this.lock,
|
|
668
|
+
() => this.api!.publishUnpublishedEntry(collection, slug),
|
|
669
|
+
'Failed to acquire publish entry lock',
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import GitHubBackend from './implementation';
|
|
2
|
+
import API from './API';
|
|
3
|
+
import AuthenticationPage from './AuthenticationPage';
|
|
4
|
+
|
|
5
|
+
export const DecapCmsBackendGithub = {
|
|
6
|
+
GitHubBackend,
|
|
7
|
+
API,
|
|
8
|
+
AuthenticationPage,
|
|
9
|
+
};
|
|
10
|
+
export { GitHubBackend, API, AuthenticationPage };
|