decap-cms-backend-forgejo 3.4.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.
@@ -0,0 +1,539 @@
1
+ import { stripIndent } from 'common-tags';
2
+ import trimStart from 'lodash/trimStart';
3
+ import semaphore from 'semaphore';
4
+ import { asyncLock, basename, blobToFileObj, Cursor, CURSOR_COMPATIBILITY_SYMBOL, entriesByFiles, entriesByFolder, filterByExtension, getBlobSHA, getMediaAsBlob, getMediaDisplayURL, runWithLock, unsentRequest, unpublishedEntries, contentKeyFromBranch, branchFromContentKey } from 'decap-cms-lib-util';
5
+ import API, { API_NAME } from './API';
6
+ import AuthenticationPage from './AuthenticationPage';
7
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
8
+ const MAX_CONCURRENT_DOWNLOADS = 10;
9
+ const {
10
+ fetchWithTimeout: fetch
11
+ } = unsentRequest;
12
+ export default class Forgejo {
13
+ constructor(config, options = {}) {
14
+ this.options = {
15
+ proxied: false,
16
+ API: null,
17
+ useWorkflow: false,
18
+ initialWorkflowStatus: '',
19
+ ...options
20
+ };
21
+ if (!this.options.proxied && (config.backend.repo === null || config.backend.repo === undefined)) {
22
+ throw new Error('The Forgejo backend needs a "repo" in the backend configuration.');
23
+ }
24
+ if (!config.backend.api_root) {
25
+ throw new Error('The Forgejo backend needs an "api_root" in the backend configuration when not proxied.');
26
+ }
27
+ this.api = this.options.API || null;
28
+ this.openAuthoringEnabled = config.backend.open_authoring || false;
29
+ if (this.openAuthoringEnabled) {
30
+ if (!this.options.useWorkflow) {
31
+ throw new Error('backend.open_authoring is true but publish_mode is not set to editorial_workflow.');
32
+ }
33
+ // In open authoring mode, defer setting this.repo until after fork selection
34
+ this.originRepo = config.backend.repo || '';
35
+ } else {
36
+ this.repo = this.originRepo = config.backend.repo || '';
37
+ }
38
+ this.alwaysForkEnabled = config.backend.always_fork || false;
39
+ this.branch = config.backend.branch?.trim() || 'main';
40
+ this.apiRoot = config.backend.api_root;
41
+ this.token = '';
42
+ this.mediaFolder = config.media_folder;
43
+ this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
44
+ this.initialWorkflowStatus = this.options.initialWorkflowStatus || 'draft';
45
+ this.lock = asyncLock();
46
+ }
47
+ isGitBackend() {
48
+ return true;
49
+ }
50
+ async status() {
51
+ const auth = (await this.api?.user().then(user => !!user).catch(e => {
52
+ console.warn('[StaticCMS] Failed getting Forgejo user', e);
53
+ return false;
54
+ })) || false;
55
+ return {
56
+ auth: {
57
+ status: auth
58
+ },
59
+ api: {
60
+ status: true,
61
+ statusPage: ''
62
+ }
63
+ };
64
+ }
65
+ authComponent() {
66
+ const wrappedAuthenticationPage = props => _jsx(AuthenticationPage, {
67
+ ...props,
68
+ backend: this
69
+ });
70
+ wrappedAuthenticationPage.displayName = 'AuthenticationPage';
71
+ return wrappedAuthenticationPage;
72
+ }
73
+ async currentUser({
74
+ token
75
+ }) {
76
+ if (!this._currentUserPromise) {
77
+ this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
78
+ headers: {
79
+ Authorization: `token ${token}`
80
+ }
81
+ }).then(res => res.json());
82
+ }
83
+ return this._currentUserPromise;
84
+ }
85
+ async userIsOriginMaintainer({
86
+ username: usernameArg,
87
+ token
88
+ }) {
89
+ const username = usernameArg || (await this.currentUser({
90
+ token
91
+ })).login;
92
+ this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
93
+ if (!this._userIsOriginMaintainerPromises[username]) {
94
+ this._userIsOriginMaintainerPromises[username] = fetch(`${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`, {
95
+ headers: {
96
+ Authorization: `token ${token}`
97
+ }
98
+ }).then(res => res.json()).then(({
99
+ permission
100
+ }) => permission === 'admin' || permission === 'write');
101
+ }
102
+ return this._userIsOriginMaintainerPromises[username];
103
+ }
104
+ async pollUntilForkExists({
105
+ repo,
106
+ token
107
+ }) {
108
+ const initialPollDelay = 250; // milliseconds
109
+ const maxPollDelay = 2000; // milliseconds
110
+ const maxWaitMs = 60000; // overall timeout in milliseconds
111
+ const startTime = Date.now();
112
+ let pollDelay = initialPollDelay;
113
+ let repoExists = false;
114
+ while (!repoExists && Date.now() - startTime < maxWaitMs) {
115
+ const response = await fetch(`${this.apiRoot}${repo}`, {
116
+ headers: {
117
+ Authorization: `token ${token}`
118
+ }
119
+ });
120
+ if (response.ok) {
121
+ repoExists = true;
122
+ } else if (response.status === 404) {
123
+ repoExists = false;
124
+ } else {
125
+ // For non-404, non-OK responses, fail fast instead of looping indefinitely.
126
+ throw new Error(`Error while checking for fork existence: ${response.status} ${response.statusText}`);
127
+ }
128
+
129
+ // wait between polls if the repo does not yet exist
130
+ if (!repoExists) {
131
+ await new Promise(resolve => setTimeout(resolve, pollDelay));
132
+ // simple backoff up to a maximum delay
133
+ pollDelay = Math.min(pollDelay * 2, maxPollDelay);
134
+ }
135
+ }
136
+ if (!repoExists) {
137
+ throw new Error('Timed out waiting for fork to be created.');
138
+ }
139
+ }
140
+ async authenticateWithFork({
141
+ userData,
142
+ getPermissionToFork
143
+ }) {
144
+ if (!this.openAuthoringEnabled) {
145
+ throw new Error('Cannot authenticate with fork; Open Authoring is turned off.');
146
+ }
147
+ const token = userData.token;
148
+
149
+ // Clear cached user data when token changes to prevent stale data across logins
150
+ this._currentUserPromise = undefined;
151
+ this._userIsOriginMaintainerPromises = {};
152
+
153
+ // Origin maintainers should be able to use the CMS normally. If alwaysFork
154
+ // is enabled we always fork (and avoid the origin maintainer check)
155
+ if (!this.alwaysForkEnabled && (await this.userIsOriginMaintainer({
156
+ token
157
+ }))) {
158
+ this.repo = this.originRepo;
159
+ this.useOpenAuthoring = false;
160
+ return Promise.resolve();
161
+ }
162
+
163
+ // If a fork exists merge it with upstream
164
+ // otherwise create a new fork.
165
+ const currentUser = await this.currentUser({
166
+ token
167
+ });
168
+ const repoName = this.originRepo.split('/')[1];
169
+ this.repo = `${currentUser.login}/${repoName}`;
170
+ this.useOpenAuthoring = true;
171
+
172
+ // Initialize or update API for fork operations
173
+ // Always recreate to ensure token and repo are up to date (unless a mock was injected for testing)
174
+ if (!this.options.API) {
175
+ const apiCtor = API;
176
+ this.api = new apiCtor({
177
+ token,
178
+ branch: this.branch,
179
+ repo: this.repo,
180
+ originRepo: this.originRepo,
181
+ apiRoot: this.apiRoot,
182
+ useOpenAuthoring: this.useOpenAuthoring,
183
+ cmsLabelPrefix: this.cmsLabelPrefix,
184
+ initialWorkflowStatus: this.initialWorkflowStatus
185
+ });
186
+ }
187
+ if (await this.api.forkExists()) {
188
+ await this.api.mergeUpstream();
189
+ return Promise.resolve();
190
+ } else {
191
+ await getPermissionToFork();
192
+ const fork = await this.api.createFork();
193
+ return this.pollUntilForkExists({
194
+ repo: `/repos/${fork.full_name}`,
195
+ token
196
+ });
197
+ }
198
+ }
199
+ restoreUser(user) {
200
+ return this.openAuthoringEnabled ? this.authenticateWithFork({
201
+ userData: user,
202
+ // no-op: restoreUser doesn't need fork approval UX
203
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
204
+ getPermissionToFork: () => {}
205
+ }).then(() => this.authenticate(user)) : this.authenticate(user);
206
+ }
207
+ async authenticate(state) {
208
+ this.token = state.token;
209
+
210
+ // Clear cached user data when token changes to prevent stale data across logins
211
+ this._currentUserPromise = undefined;
212
+ this._userIsOriginMaintainerPromises = {};
213
+ const apiCtor = API;
214
+ this.api = new apiCtor({
215
+ token: this.token,
216
+ branch: this.branch,
217
+ repo: this.repo,
218
+ originRepo: this.originRepo,
219
+ apiRoot: this.apiRoot,
220
+ useOpenAuthoring: this.useOpenAuthoring,
221
+ cmsLabelPrefix: this.cmsLabelPrefix,
222
+ initialWorkflowStatus: this.initialWorkflowStatus
223
+ });
224
+ const user = await this.api.user();
225
+ const isCollab = await this.api.hasWriteAccess().catch(error => {
226
+ error.message = stripIndent`
227
+ Repo "${this.repo}" not found.
228
+
229
+ Please ensure the repo information is spelled correctly.
230
+
231
+ If the repo is private, make sure you're logged into a Forgejo account with access.
232
+
233
+ If your repo is under an organization, ensure the organization has granted access to Static
234
+ CMS.
235
+ `;
236
+ throw error;
237
+ });
238
+
239
+ // Unauthorized user
240
+ if (!isCollab) {
241
+ throw new Error('Your Forgejo user account does not have access to this repo.');
242
+ }
243
+
244
+ // Authorized user
245
+ return {
246
+ name: user.full_name,
247
+ login: user.login,
248
+ email: user.email,
249
+ avatar_url: user.avatar_url,
250
+ token: state.token,
251
+ useOpenAuthoring: this.useOpenAuthoring
252
+ };
253
+ }
254
+ logout() {
255
+ this.token = null;
256
+
257
+ // Clear cached user data on logout
258
+ this._currentUserPromise = undefined;
259
+ this._userIsOriginMaintainerPromises = {};
260
+ if (this.api && this.api.reset && typeof this.api.reset === 'function') {
261
+ return this.api.reset();
262
+ }
263
+ }
264
+ getToken() {
265
+ return Promise.resolve(this.token);
266
+ }
267
+ getCursorAndFiles = (files, page) => {
268
+ const pageSize = 20;
269
+ const count = files.length;
270
+ const pageCount = Math.ceil(files.length / pageSize);
271
+ const actions = [];
272
+ if (page > 1) {
273
+ actions.push('prev');
274
+ actions.push('first');
275
+ }
276
+ if (page < pageCount) {
277
+ actions.push('next');
278
+ actions.push('last');
279
+ }
280
+ const cursor = Cursor.create({
281
+ actions,
282
+ meta: {
283
+ page,
284
+ count,
285
+ pageSize,
286
+ pageCount
287
+ },
288
+ data: {
289
+ files
290
+ }
291
+ });
292
+ const pageFiles = files.slice((page - 1) * pageSize, page * pageSize);
293
+ return {
294
+ cursor,
295
+ files: pageFiles
296
+ };
297
+ };
298
+ async entriesByFolder(folder, extension, depth) {
299
+ const repoURL = this.api.originRepoURL;
300
+ let cursor;
301
+ const listFiles = () => this.api.listFiles(folder, {
302
+ repoURL,
303
+ depth
304
+ }).then(files => {
305
+ const filtered = files.filter(file => filterByExtension(file, extension));
306
+ const result = this.getCursorAndFiles(filtered, 1);
307
+ cursor = result.cursor;
308
+ return result.files;
309
+ });
310
+ const readFile = (path, id) => this.api.readFile(path, id, {
311
+ repoURL
312
+ });
313
+ const files = await entriesByFolder(listFiles, readFile, this.api.readFileMetadata.bind(this.api), API_NAME);
314
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
315
+ // @ts-ignore
316
+ files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
317
+ return files;
318
+ }
319
+ async allEntriesByFolder(folder, extension, depth) {
320
+ const repoURL = this.api.originRepoURL;
321
+ const listFiles = () => this.api.listFiles(folder, {
322
+ repoURL,
323
+ depth
324
+ }).then(files => files.filter(file => filterByExtension(file, extension)));
325
+ const readFile = (path, id) => {
326
+ return this.api.readFile(path, id, {
327
+ repoURL
328
+ });
329
+ };
330
+ const files = await entriesByFolder(listFiles, readFile, this.api.readFileMetadata.bind(this.api), API_NAME);
331
+ return files;
332
+ }
333
+ entriesByFiles(files) {
334
+ const repoURL = this.useOpenAuthoring ? this.api.originRepoURL : this.api.repoURL;
335
+ const readFile = (path, id) => this.api.readFile(path, id, {
336
+ repoURL
337
+ }).catch(() => '');
338
+ return entriesByFiles(files, readFile, this.api.readFileMetadata.bind(this.api), API_NAME);
339
+ }
340
+
341
+ // Fetches a single entry.
342
+ getEntry(path) {
343
+ const repoURL = this.api.originRepoURL;
344
+ return this.api.readFile(path, null, {
345
+ repoURL
346
+ }).then(data => ({
347
+ file: {
348
+ path,
349
+ id: null
350
+ },
351
+ data: data
352
+ })).catch(() => ({
353
+ file: {
354
+ path,
355
+ id: null
356
+ },
357
+ data: ''
358
+ }));
359
+ }
360
+ async getMedia(mediaFolder = this.mediaFolder, folderSupport) {
361
+ if (!mediaFolder) {
362
+ return [];
363
+ }
364
+ return this.api.listFiles(mediaFolder, undefined, folderSupport).then(files => files.map(({
365
+ id,
366
+ name,
367
+ size,
368
+ path,
369
+ type
370
+ }) => {
371
+ return {
372
+ id,
373
+ name,
374
+ size,
375
+ displayURL: {
376
+ id,
377
+ path
378
+ },
379
+ path,
380
+ isDirectory: type === 'tree'
381
+ };
382
+ }));
383
+ }
384
+ async getMediaFile(path) {
385
+ const blob = await getMediaAsBlob(path, null, this.api.readFile.bind(this.api));
386
+ const name = basename(path);
387
+ const fileObj = blobToFileObj(name, blob);
388
+ const url = URL.createObjectURL(fileObj);
389
+ const id = await getBlobSHA(blob);
390
+ return {
391
+ id,
392
+ displayURL: url,
393
+ path,
394
+ name,
395
+ size: fileObj.size,
396
+ file: fileObj,
397
+ url
398
+ };
399
+ }
400
+ getMediaDisplayURL(displayURL) {
401
+ this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
402
+ return getMediaDisplayURL(displayURL, this.api.readFile.bind(this.api), this._mediaDisplayURLSem);
403
+ }
404
+ persistEntry(entry, options) {
405
+ // persistEntry is a transactional operation
406
+ return runWithLock(this.lock, () => {
407
+ if (options.useWorkflow) {
408
+ const slug = entry.dataFiles[0].slug;
409
+ const collection = options.collectionName;
410
+ const files = [...entry.dataFiles, ...entry.assets];
411
+ return this.api.editorialWorkflowGit(files, slug, collection, options);
412
+ }
413
+ return this.api.persistFiles(entry.dataFiles, entry.assets, options);
414
+ }, 'Failed to acquire persist entry lock');
415
+ }
416
+ async persistMedia(mediaFile, options) {
417
+ try {
418
+ await this.api.persistFiles([], [mediaFile], options);
419
+ const {
420
+ sha,
421
+ path,
422
+ fileObj
423
+ } = mediaFile;
424
+ const displayURL = URL.createObjectURL(fileObj);
425
+ return {
426
+ id: sha,
427
+ name: fileObj.name,
428
+ size: fileObj.size,
429
+ displayURL,
430
+ path: trimStart(path, '/')
431
+ };
432
+ } catch (error) {
433
+ console.error(error);
434
+ throw error;
435
+ }
436
+ }
437
+ async deleteFiles(paths, commitMessage) {
438
+ await this.api.deleteFiles(paths, commitMessage);
439
+ }
440
+ async traverseCursor(cursor, action) {
441
+ const meta = cursor.meta;
442
+ const files = cursor.data.get('files').toJS();
443
+ let result;
444
+ switch (action) {
445
+ case 'first':
446
+ {
447
+ result = this.getCursorAndFiles(files, 1);
448
+ break;
449
+ }
450
+ case 'last':
451
+ {
452
+ result = this.getCursorAndFiles(files, meta.get('pageCount'));
453
+ break;
454
+ }
455
+ case 'next':
456
+ {
457
+ result = this.getCursorAndFiles(files, meta.get('page') + 1);
458
+ break;
459
+ }
460
+ case 'prev':
461
+ {
462
+ result = this.getCursorAndFiles(files, meta.get('page') - 1);
463
+ break;
464
+ }
465
+ default:
466
+ {
467
+ result = this.getCursorAndFiles(files, 1);
468
+ break;
469
+ }
470
+ }
471
+ const readFile = (path, id) => this.api.readFile(path, id, {
472
+ repoURL: this.api.originRepoURL
473
+ }).catch(() => '');
474
+ const entries = await entriesByFiles(result.files, readFile, this.api.readFileMetadata.bind(this.api), API_NAME);
475
+ return {
476
+ entries,
477
+ cursor: result.cursor
478
+ };
479
+ }
480
+ async unpublishedEntries() {
481
+ const listEntriesKeys = () => this.api.listUnpublishedBranches().then(branches => branches.map(branch => contentKeyFromBranch(branch)));
482
+ const ids = await unpublishedEntries(listEntriesKeys);
483
+ return ids;
484
+ }
485
+ async unpublishedEntry({
486
+ id,
487
+ collection,
488
+ slug
489
+ }) {
490
+ if (id) {
491
+ const data = await this.api.retrieveUnpublishedEntryData(id);
492
+ return data;
493
+ } else if (collection && slug) {
494
+ const contentKey = this.api.generateContentKey(collection, slug);
495
+ const data = await this.api.retrieveUnpublishedEntryData(contentKey);
496
+ return data;
497
+ } else {
498
+ throw new Error('Missing unpublished entry id or collection and slug');
499
+ }
500
+ }
501
+ async unpublishedEntryDataFile(collection, slug, path, id) {
502
+ const contentKey = this.api.generateContentKey(collection, slug);
503
+ const branch = branchFromContentKey(contentKey);
504
+ const data = await this.api.readFile(path, id, {
505
+ branch
506
+ });
507
+ return data;
508
+ }
509
+ async unpublishedEntryMediaFile(collection, slug, path, id) {
510
+ const contentKey = this.api.generateContentKey(collection, slug);
511
+ const branch = branchFromContentKey(contentKey);
512
+ const blob = await this.api.readFile(path, id, {
513
+ branch,
514
+ parseText: false
515
+ });
516
+ const name = basename(path);
517
+ const fileObj = blobToFileObj(name, blob);
518
+ return {
519
+ id,
520
+ name,
521
+ path,
522
+ size: fileObj.size,
523
+ displayURL: URL.createObjectURL(fileObj),
524
+ file: fileObj
525
+ };
526
+ }
527
+ updateUnpublishedEntryStatus(collection, slug, newStatus) {
528
+ return runWithLock(this.lock, () => this.api.updateUnpublishedEntryStatus(collection, slug, newStatus), 'Failed to acquire update entry status lock');
529
+ }
530
+ publishUnpublishedEntry(collection, slug) {
531
+ return runWithLock(this.lock, () => this.api.publishUnpublishedEntry(collection, slug), 'Failed to acquire publish entry lock');
532
+ }
533
+ deleteUnpublishedEntry(collection, slug) {
534
+ return runWithLock(this.lock, () => this.api.deleteUnpublishedEntry(collection, slug), 'Failed to acquire delete entry lock');
535
+ }
536
+ async getDeployPreview() {
537
+ return null;
538
+ }
539
+ }
@@ -0,0 +1,9 @@
1
+ import ForgejoBackend from './implementation';
2
+ import API from './API';
3
+ import AuthenticationPage from './AuthenticationPage';
4
+ export const DecapCmsBackendForgejo = {
5
+ ForgejoBackend,
6
+ API,
7
+ AuthenticationPage
8
+ };
9
+ export { API, AuthenticationPage, ForgejoBackend };
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "decap-cms-backend-forgejo",
3
+ "description": "Forgejo backend for Decap CMS",
4
+ "version": "3.4.0",
5
+ "repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-backend-forgejo",
6
+ "bugs": "https://github.com/decaporg/decap-cms/issues",
7
+ "license": "MIT",
8
+ "module": "dist/esm/index.js",
9
+ "main": "dist/decap-cms-backend-forgejo.js",
10
+ "keywords": [
11
+ "decap-cms",
12
+ "backend",
13
+ "forgejo"
14
+ ],
15
+ "sideEffects": false,
16
+ "scripts": {
17
+ "develop": "npm run build:esm -- --watch",
18
+ "build": "cross-env NODE_ENV=production webpack",
19
+ "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
20
+ },
21
+ "dependencies": {
22
+ "common-tags": "^1.8.0",
23
+ "js-base64": "^3.0.0",
24
+ "semaphore": "^1.1.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@emotion/react": "^11.11.1",
28
+ "@emotion/styled": "^11.11.0",
29
+ "decap-cms-lib-auth": "^3.0.0",
30
+ "decap-cms-lib-util": "^3.0.0",
31
+ "decap-cms-ui-default": "^3.0.0",
32
+ "immutable": "^3.7.6",
33
+ "lodash": "^4.17.11",
34
+ "prop-types": "^15.7.2",
35
+ "react": "^19.1.0"
36
+ },
37
+ "gitHead": "ffe5ab7e61f4e7cb374a413bf9fe55f088d20ba2"
38
+ }