decap-cms-backend-gitea 3.0.4

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/src/API.ts ADDED
@@ -0,0 +1,463 @@
1
+ import { Base64 } from 'js-base64';
2
+ import { trimStart, trim, result, partial, last, initial } from 'lodash';
3
+ import {
4
+ APIError,
5
+ basename,
6
+ generateContentKey,
7
+ getAllResponses,
8
+ localForage,
9
+ parseContentKey,
10
+ readFileMetadata,
11
+ requestWithBackoff,
12
+ unsentRequest,
13
+ } from 'decap-cms-lib-util';
14
+
15
+ import type {
16
+ DataFile,
17
+ PersistOptions,
18
+ AssetProxy,
19
+ ApiRequest,
20
+ FetchError,
21
+ } from 'decap-cms-lib-util';
22
+ import type { Semaphore } from 'semaphore';
23
+ import type {
24
+ FilesResponse,
25
+ GitGetBlobResponse,
26
+ GitGetTreeResponse,
27
+ GiteaUser,
28
+ GiteaRepository,
29
+ ReposListCommitsResponse,
30
+ } from './types';
31
+
32
+ export const API_NAME = 'Gitea';
33
+
34
+ export interface Config {
35
+ apiRoot?: string;
36
+ token?: string;
37
+ branch?: string;
38
+ repo?: string;
39
+ originRepo?: string;
40
+ }
41
+
42
+ enum FileOperation {
43
+ CREATE = 'create',
44
+ DELETE = 'delete',
45
+ UPDATE = 'update',
46
+ }
47
+
48
+ export interface ChangeFileOperation {
49
+ content?: string;
50
+ from_path?: string;
51
+ path: string;
52
+ operation: FileOperation;
53
+ sha?: string;
54
+ }
55
+
56
+ interface MetaDataObjects {
57
+ entry: { path: string; sha: string };
58
+ files: MediaFile[];
59
+ }
60
+
61
+ export interface Metadata {
62
+ type: string;
63
+ objects: MetaDataObjects;
64
+ branch: string;
65
+ status: string;
66
+ collection: string;
67
+ commitMessage: string;
68
+ version?: string;
69
+ user: string;
70
+ title?: string;
71
+ description?: string;
72
+ timeStamp: string;
73
+ }
74
+
75
+ export interface BlobArgs {
76
+ sha: string;
77
+ repoURL: string;
78
+ parseText: boolean;
79
+ }
80
+
81
+ type Param = string | number | undefined;
82
+
83
+ export type Options = RequestInit & {
84
+ params?: Record<string, Param | Record<string, Param> | string[]>;
85
+ };
86
+
87
+ type MediaFile = {
88
+ sha: string;
89
+ path: string;
90
+ };
91
+
92
+ export default class API {
93
+ apiRoot: string;
94
+ token: string;
95
+ branch: string;
96
+ repo: string;
97
+ originRepo: string;
98
+ repoOwner: string;
99
+ repoName: string;
100
+ originRepoOwner: string;
101
+ originRepoName: string;
102
+ repoURL: string;
103
+ originRepoURL: string;
104
+
105
+ _userPromise?: Promise<GiteaUser>;
106
+ _metadataSemaphore?: Semaphore;
107
+
108
+ commitAuthor?: {};
109
+
110
+ constructor(config: Config) {
111
+ this.apiRoot = config.apiRoot || 'https://try.gitea.io/api/v1';
112
+ this.token = config.token || '';
113
+ this.branch = config.branch || 'master';
114
+ this.repo = config.repo || '';
115
+ this.originRepo = config.originRepo || this.repo;
116
+ this.repoURL = `/repos/${this.repo}`;
117
+ this.originRepoURL = `/repos/${this.originRepo}`;
118
+
119
+ const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
120
+ this.repoOwner = repoParts[0];
121
+ this.repoName = repoParts[1];
122
+
123
+ this.originRepoOwner = originRepoParts[0];
124
+ this.originRepoName = originRepoParts[1];
125
+ }
126
+
127
+ static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
128
+
129
+ user(): Promise<{ full_name: string; login: string; avatar_url: string }> {
130
+ if (!this._userPromise) {
131
+ this._userPromise = this.getUser();
132
+ }
133
+ return this._userPromise;
134
+ }
135
+
136
+ getUser() {
137
+ return this.request('/user') as Promise<GiteaUser>;
138
+ }
139
+
140
+ async hasWriteAccess() {
141
+ try {
142
+ const result: GiteaRepository = await this.request(this.repoURL);
143
+ // update config repoOwner to avoid case sensitivity issues with Gitea
144
+ this.repoOwner = result.owner.login;
145
+ return result.permissions.push;
146
+ } catch (error) {
147
+ console.error('Problem fetching repo data from Gitea');
148
+ throw error;
149
+ }
150
+ }
151
+
152
+ reset() {
153
+ // no op
154
+ }
155
+
156
+ requestHeaders(headers = {}) {
157
+ const baseHeader: Record<string, string> = {
158
+ 'Content-Type': 'application/json; charset=utf-8',
159
+ ...headers,
160
+ };
161
+
162
+ if (this.token) {
163
+ baseHeader.Authorization = `token ${this.token}`;
164
+ return Promise.resolve(baseHeader);
165
+ }
166
+
167
+ return Promise.resolve(baseHeader);
168
+ }
169
+
170
+ async parseJsonResponse(response: Response) {
171
+ const json = await response.json();
172
+ if (!response.ok) {
173
+ return Promise.reject(json);
174
+ }
175
+ return json;
176
+ }
177
+
178
+ urlFor(path: string, options: Options) {
179
+ const params = [];
180
+ if (options.params) {
181
+ for (const key in options.params) {
182
+ params.push(`${key}=${encodeURIComponent(options.params[key] as string)}`);
183
+ }
184
+ }
185
+ if (params.length) {
186
+ path += `?${params.join('&')}`;
187
+ }
188
+ return this.apiRoot + path;
189
+ }
190
+
191
+ parseResponse(response: Response) {
192
+ const contentType = response.headers.get('Content-Type');
193
+ if (contentType && contentType.match(/json/)) {
194
+ return this.parseJsonResponse(response);
195
+ }
196
+ const textPromise = response.text().then(text => {
197
+ if (!response.ok) {
198
+ return Promise.reject(text);
199
+ }
200
+ return text;
201
+ });
202
+ return textPromise;
203
+ }
204
+
205
+ handleRequestError(error: FetchError, responseStatus: number) {
206
+ throw new APIError(error.message, responseStatus, API_NAME);
207
+ }
208
+
209
+ buildRequest(req: ApiRequest) {
210
+ return req;
211
+ }
212
+
213
+ async request(
214
+ path: string,
215
+ options: Options = {},
216
+ parser = (response: Response) => this.parseResponse(response),
217
+ ) {
218
+ options = { cache: 'no-cache', ...options };
219
+ const headers = await this.requestHeaders(options.headers || {});
220
+ const url = this.urlFor(path, options);
221
+ let responseStatus = 500;
222
+
223
+ try {
224
+ const req = unsentRequest.fromFetchArguments(url, {
225
+ ...options,
226
+ headers,
227
+ }) as unknown as ApiRequest;
228
+ const response = await requestWithBackoff(this, req);
229
+ responseStatus = response.status;
230
+ const parsedResponse = await parser(response);
231
+ return parsedResponse;
232
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
233
+ } catch (error: any) {
234
+ return this.handleRequestError(error, responseStatus);
235
+ }
236
+ }
237
+
238
+ nextUrlProcessor() {
239
+ return (url: string) => url;
240
+ }
241
+
242
+ async requestAllPages<T>(url: string, options: Options = {}) {
243
+ options = { cache: 'no-cache', ...options };
244
+ const headers = await this.requestHeaders(options.headers || {});
245
+ const processedURL = this.urlFor(url, options);
246
+ const allResponses = await getAllResponses(
247
+ processedURL,
248
+ { ...options, headers },
249
+ 'next',
250
+ this.nextUrlProcessor(),
251
+ );
252
+ const pages: T[][] = await Promise.all(
253
+ allResponses.map((res: Response) => this.parseResponse(res)),
254
+ );
255
+ return ([] as T[]).concat(...pages);
256
+ }
257
+
258
+ generateContentKey(collectionName: string, slug: string) {
259
+ return generateContentKey(collectionName, slug);
260
+ }
261
+
262
+ parseContentKey(contentKey: string) {
263
+ return parseContentKey(contentKey);
264
+ }
265
+
266
+ async readFile(
267
+ path: string,
268
+ sha?: string | null,
269
+ {
270
+ branch = this.branch,
271
+ repoURL = this.repoURL,
272
+ parseText = true,
273
+ }: {
274
+ branch?: string;
275
+ repoURL?: string;
276
+ parseText?: boolean;
277
+ } = {},
278
+ ) {
279
+ if (!sha) {
280
+ sha = await this.getFileSha(path, { repoURL, branch });
281
+ }
282
+ const content = await this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
283
+ return content;
284
+ }
285
+
286
+ async readFileMetadata(path: string, sha: string | null | undefined) {
287
+ const fetchFileMetadata = async () => {
288
+ try {
289
+ const result: ReposListCommitsResponse = await this.request(
290
+ `${this.originRepoURL}/commits`,
291
+ {
292
+ params: { path, sha: this.branch, stat: 'false' },
293
+ },
294
+ );
295
+ const { commit } = result[0];
296
+ return {
297
+ author: commit.author.name || commit.author.email,
298
+ updatedOn: commit.author.date,
299
+ };
300
+ } catch (e) {
301
+ return { author: '', updatedOn: '' };
302
+ }
303
+ };
304
+ const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
305
+ return fileMetadata;
306
+ }
307
+
308
+ async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
309
+ const result: GitGetBlobResponse = await this.request(`${repoURL}/git/blobs/${sha}`, {
310
+ cache: 'force-cache',
311
+ });
312
+
313
+ if (parseText) {
314
+ // treat content as a utf-8 string
315
+ const content = Base64.decode(result.content);
316
+ return content;
317
+ } else {
318
+ // treat content as binary and convert to blob
319
+ const content = Base64.atob(result.content);
320
+ const byteArray = new Uint8Array(content.length);
321
+ for (let i = 0; i < content.length; i++) {
322
+ byteArray[i] = content.charCodeAt(i);
323
+ }
324
+ const blob = new Blob([byteArray]);
325
+ return blob;
326
+ }
327
+ }
328
+
329
+ async listFiles(
330
+ path: string,
331
+ { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
332
+ folderSupport?: boolean,
333
+ ): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
334
+ const folder = trim(path, '/');
335
+ try {
336
+ const result: GitGetTreeResponse = await this.request(
337
+ `${repoURL}/git/trees/${branch}:${encodeURIComponent(folder)}`,
338
+ {
339
+ // Gitea API supports recursive=1 for getting the entire recursive tree
340
+ // or omitting it to get the non-recursive tree
341
+ params: depth > 1 ? { recursive: 1 } : {},
342
+ },
343
+ );
344
+ return (
345
+ result.tree
346
+ // filter only files and/or folders up to the required depth
347
+ .filter(
348
+ file =>
349
+ (!folderSupport ? file.type === 'blob' : true) &&
350
+ decodeURIComponent(file.path).split('/').length <= depth,
351
+ )
352
+ .map(file => ({
353
+ type: file.type,
354
+ id: file.sha,
355
+ name: basename(file.path),
356
+ path: `${folder}/${file.path}`,
357
+ size: file.size!,
358
+ }))
359
+ );
360
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
361
+ } catch (err: any) {
362
+ if (err && err.status === 404) {
363
+ console.info('[StaticCMS] This 404 was expected and handled appropriately.');
364
+ return [];
365
+ } else {
366
+ throw err;
367
+ }
368
+ }
369
+ }
370
+
371
+ async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
372
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
373
+ const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
374
+ const operations = await this.getChangeFileOperations(files, this.branch);
375
+ return this.changeFiles(operations, options);
376
+ }
377
+
378
+ async changeFiles(operations: ChangeFileOperation[], options: PersistOptions) {
379
+ return (await this.request(`${this.repoURL}/contents`, {
380
+ method: 'POST',
381
+ body: JSON.stringify({
382
+ branch: this.branch,
383
+ files: operations,
384
+ message: options.commitMessage,
385
+ }),
386
+ })) as FilesResponse;
387
+ }
388
+
389
+ async getChangeFileOperations(files: { path: string; newPath?: string }[], branch: string) {
390
+ const items: ChangeFileOperation[] = await Promise.all(
391
+ files.map(async file => {
392
+ const content = await result(
393
+ file,
394
+ 'toBase64',
395
+ partial(this.toBase64, (file as DataFile).raw),
396
+ );
397
+ let sha;
398
+ let operation;
399
+ let from_path;
400
+ let path = trimStart(file.path, '/');
401
+ try {
402
+ sha = await this.getFileSha(file.path, { branch });
403
+ operation = FileOperation.UPDATE;
404
+ from_path = file.newPath && path;
405
+ path = file.newPath ? trimStart(file.newPath, '/') : path;
406
+ } catch {
407
+ sha = undefined;
408
+ operation = FileOperation.CREATE;
409
+ }
410
+
411
+ return {
412
+ operation,
413
+ content,
414
+ path,
415
+ from_path,
416
+ sha,
417
+ } as ChangeFileOperation;
418
+ }),
419
+ );
420
+ return items;
421
+ }
422
+
423
+ async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
424
+ /**
425
+ * We need to request the tree first to get the SHA. We use extended SHA-1
426
+ * syntax (<rev>:<path>) to get a blob from a tree without having to recurse
427
+ * through the tree.
428
+ */
429
+
430
+ const pathArray = path.split('/');
431
+ const filename = last(pathArray);
432
+ const directory = initial(pathArray).join('/');
433
+ const fileDataPath = encodeURIComponent(directory);
434
+ const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`;
435
+
436
+ const result: GitGetTreeResponse = await this.request(fileDataURL);
437
+ const file = result.tree.find(file => file.path === filename);
438
+ if (file) {
439
+ return file.sha;
440
+ } else {
441
+ throw new APIError('Not Found', 404, API_NAME);
442
+ }
443
+ }
444
+
445
+ async deleteFiles(paths: string[], message: string) {
446
+ const operations: ChangeFileOperation[] = await Promise.all(
447
+ paths.map(async path => {
448
+ const sha = await this.getFileSha(path);
449
+
450
+ return {
451
+ operation: FileOperation.DELETE,
452
+ path,
453
+ sha,
454
+ } as ChangeFileOperation;
455
+ }),
456
+ );
457
+ this.changeFiles(operations, { commitMessage: message });
458
+ }
459
+
460
+ toBase64(str: string) {
461
+ return Promise.resolve(Base64.encode(str));
462
+ }
463
+ }
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import styled from '@emotion/styled';
4
+ import { PkceAuthenticator } from 'decap-cms-lib-auth';
5
+ import { AuthenticationPage, Icon } from 'decap-cms-ui-default';
6
+
7
+ const LoginButtonIcon = styled(Icon)`
8
+ margin-right: 18px;
9
+ `;
10
+
11
+ export default class GiteaAuthenticationPage extends React.Component {
12
+ static propTypes = {
13
+ inProgress: PropTypes.bool,
14
+ config: PropTypes.object.isRequired,
15
+ onLogin: PropTypes.func.isRequired,
16
+ t: PropTypes.func.isRequired,
17
+ };
18
+
19
+ state = {};
20
+
21
+ componentDidMount() {
22
+ const { base_url = 'https://try.gitea.io', app_id = '' } = this.props.config.backend;
23
+ this.auth = new PkceAuthenticator({
24
+ base_url,
25
+ auth_endpoint: 'login/oauth/authorize',
26
+ app_id,
27
+ auth_token_endpoint: 'login/oauth/access_token',
28
+ });
29
+ // Complete authentication if we were redirected back to from the provider.
30
+ this.auth.completeAuth((err, data) => {
31
+ if (err) {
32
+ this.setState({ loginError: err.toString() });
33
+ return;
34
+ } else if (data) {
35
+ this.props.onLogin(data);
36
+ }
37
+ });
38
+ }
39
+
40
+ handleLogin = e => {
41
+ e.preventDefault();
42
+ this.auth.authenticate({ scope: 'repository' }, (err, data) => {
43
+ if (err) {
44
+ this.setState({ loginError: err.toString() });
45
+ return;
46
+ }
47
+ this.props.onLogin(data);
48
+ });
49
+ };
50
+
51
+ render() {
52
+ const { inProgress, config, t } = this.props;
53
+ return (
54
+ <AuthenticationPage
55
+ onLogin={this.handleLogin}
56
+ loginDisabled={inProgress}
57
+ loginErrorMessage={this.state.loginError}
58
+ logoUrl={config.logoUrl}
59
+ siteUrl={config.siteUrl}
60
+ renderButtonContent={() => (
61
+ <React.Fragment>
62
+ <LoginButtonIcon type="gitea" />{' '}
63
+ {inProgress ? t('auth.loggingIn') : t('auth.loginWithGitea')}
64
+ </React.Fragment>
65
+ )}
66
+ t={t}
67
+ />
68
+ );
69
+ }
70
+ }