@silexlabs/silex 3.0.6 → 3.0.8

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.
@@ -15,14 +15,15 @@
15
15
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
  */
17
17
 
18
- import { API_CONNECTOR_LOGIN_CALLBACK, API_CONNECTOR_PATH, API_PATH, WEBSITE_DATA_FILE } from '../../constants'
18
+ import { API_CONNECTOR_LOGIN_CALLBACK, API_CONNECTOR_PATH, API_PATH, WEBSITE_DATA_FILE, WEBSITE_PAGES_FOLDER } from '../../constants'
19
19
  import { ServerConfig } from '../../server/config'
20
20
  import { ConnectorFile, ConnectorFileContent, StatusCallback, StorageConnector, contentToBuffer, contentToString, toConnectorData } from '../../server/connectors/connectors'
21
- import { ApiError, ConnectorType, ConnectorUser, WebsiteData, WebsiteId, WebsiteMeta, WebsiteMetaFileContent, JobStatus } from '../../types'
21
+ import { ApiError, ConnectorType, ConnectorUser, WebsiteData, WebsiteId, WebsiteMeta, WebsiteMetaFileContent, JobStatus, Page } from '../../types'
22
22
  import fetch from 'node-fetch'
23
23
  import crypto, { createHash } from 'crypto'
24
24
  import { join } from 'path'
25
25
  import { Agent } from 'https'
26
+ import { getPageSlug } from '../../page'
26
27
 
27
28
  /**
28
29
  * Gitlab connector
@@ -30,17 +31,19 @@ import { Agent } from 'https'
30
31
  * @see https://docs.gitlab.com/ee/api/oauth2.html
31
32
  */
32
33
 
34
+ const MAX_BATCH_UPLOAD_SIZE = 100
35
+
33
36
  export interface GitlabOptions {
34
37
  clientId: string
35
38
  clientSecret: string
36
39
  branch: string
37
40
  assetsFolder: string
38
- //metaRepo: string
39
- //metaRepoFile: string
40
41
  repoPrefix: string
41
42
  scope: string
42
43
  domain: string
43
44
  timeOut: number
45
+ //metaRepo: string
46
+ //metaRepoFile: string
44
47
  }
45
48
 
46
49
  export interface GitlabToken {
@@ -112,16 +115,26 @@ interface GitlabFetchCommits {
112
115
  since: string
113
116
  }
114
117
 
115
- interface MetaRepoFileContent {
116
- websites: {
117
- [websiteId: string]: {
118
- meta: WebsiteMetaFileContent,
119
- createdAt: string,
120
- updatedAt: string,
121
- }
122
- }
118
+ interface GitlabPage {
119
+ id: string
120
+ name: string
121
+ isFile: true
123
122
  }
124
123
 
124
+ interface GitlabWebsiteData {
125
+ pages: GitlabPage[]
126
+ }
127
+
128
+ // interface MetaRepoFileContent {
129
+ // websites: {
130
+ // [websiteId: string]: {
131
+ // meta: WebsiteMetaFileContent,
132
+ // createdAt: string,
133
+ // updatedAt: string,
134
+ // }
135
+ // }
136
+ // }
137
+
125
138
  const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
126
139
  <svg
127
140
  xmlns:svg="http://www.w3.org/2000/svg"
@@ -200,16 +213,18 @@ export default class GitlabConnector implements StorageConnector {
200
213
  scope: 'api', // 'api+read_api+read_user+read_repository+write_repository+email+sudo+profile+openid'
201
214
  ...opts,
202
215
  } as GitlabOptions
203
- if(!this.options.clientId) throw new Error('Missing Gitlab client ID')
204
- if(!this.options.clientSecret) throw new Error('Missing Gitlab client secret')
205
- if(!this.options.domain) throw new Error('Missing Gitlab domain')
206
- if(!this.options.timeOut) this.options.timeOut = 15000 /* default value */
216
+ if (!this.options.clientId) throw new Error('Missing Gitlab client ID')
217
+ if (!this.options.clientSecret) throw new Error('Missing Gitlab client secret')
218
+ if (!this.options.domain) throw new Error('Missing Gitlab domain')
219
+ if (!this.options.timeOut) this.options.timeOut = 15000 /* default value */
207
220
  }
208
221
 
209
222
  // **
210
223
  // Convenience methods for the Gitlab API
211
- private getAssetPath(path: string): string {
212
- return encodeURIComponent(join(this.options.assetsFolder, path))
224
+ private getAssetPath(path: string, encode = true): string {
225
+ const resolvedPath = join(this.options.assetsFolder, path)
226
+ if (encode) return encodeURIComponent(resolvedPath)
227
+ return resolvedPath
213
228
  }
214
229
 
215
230
  isUsingOfficialInstance(): boolean {
@@ -244,55 +259,15 @@ export default class GitlabConnector implements StorageConnector {
244
259
  }
245
260
 
246
261
  async readFile(session: GitlabSession, websiteId: string, fileName: string): Promise<Buffer> {
262
+ // Remove leading slash
247
263
  const safePath = fileName.replace(/^\//, '')
248
- // Call the API
249
- const url = `${this.options.domain}/api/v4/projects/${websiteId}/repository/files/${safePath}?ref=${this.options.branch}&access_token=${this.getSessionToken(session).token?.access_token}`
250
- const response = await fetch(url, {
251
- agent: this.getAgent(),
252
- method: 'GET',
253
- headers: {
254
- 'Content-Type': 'application/json',
255
- },
256
- })
257
- const json = await response.json()
258
- if(!response.ok) throw new ApiError(`Error reading file "${fileName}" from Gitlab: ${json?.message ?? json?.error ?? response.statusText}`, response.status)
259
- // From base64 string to buffer
260
- const buf = Buffer.from(json.content, 'base64')
261
- // Return the image bytes
262
- return buf
264
+ return this.downloadRawFile(session, websiteId, safePath)
263
265
  }
264
266
 
265
- /*
266
- * Get the meta repo path for the current user
267
- * The meta repo contains a JSON file which contains the list of websites
268
- */
269
- //private getMetaRepoPath(session: GitlabSession): string {
270
- // if(!this.getSessionToken(session).username) throw new ApiError('Missing Gitlab user ID. User not logged in?', 401)
271
- // return encodeURIComponent(`${this.getSessionToken(session).username}/${this.options.metaRepo}`)
272
- //}
273
-
274
- ///**
275
- // * Initialize the storage with a meta repo
276
- // */
277
- //private async initStorage(session: GitlabSession): Promise<void> {
278
- // // Create the meta repo
279
- // try {
280
- // const project = await this.callApi(session, 'api/v4/projects/', 'POST', {
281
- // name: this.options.metaRepo,
282
- // }) as any
283
- // return this.createFile(session, this.getMetaRepoPath(session), this.options.metaRepoFile, JSON.stringify({
284
- // websites: {}
285
- // } as MetaRepoFileContent))
286
- // } catch (e) {
287
- // console.error('Could not init storage', e.statusCode, e.httpStatusCode, e)
288
- // throw e
289
- // }
290
- //}
291
-
292
267
  /**
293
268
  * Call the Gitlab API with the user's token and handle errors
294
269
  */
295
- async callApi(session: GitlabSession, path: string, method: 'POST' | 'GET' | 'PUT' | 'DELETE' = 'GET', body: GitlabWriteFile | GitlabGetToken | GitlabWebsiteName | GitlabCreateBranch | GitlabGetTags | GitlabCreateTag | GitlabFetchCommits | null = null, params: any = {}): Promise<any> {
270
+ async callApi(session: GitlabSession, path: string, method: 'POST' | 'GET' | 'PUT' | 'DELETE' = 'GET', requestBody: GitlabWriteFile | GitlabGetToken | GitlabWebsiteName | GitlabCreateBranch | GitlabGetTags | GitlabCreateTag | GitlabFetchCommits | null = null, params: any = {}): Promise<any> {
296
271
  const token = this.getSessionToken(session).token
297
272
  const tokenParam = token ? `access_token=${token.access_token}&` : ''
298
273
  const paramsStr = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent((v as any).toString())}`).join('&')
@@ -300,17 +275,21 @@ export default class GitlabConnector implements StorageConnector {
300
275
  const headers = {
301
276
  'Content-Type': 'application/json',
302
277
  }
303
- if (method === 'GET' && body) {
304
- console.error('Gitlab API error (4) - GET request with body', {url, method, body, params})
278
+ if (method === 'GET' && requestBody) {
279
+ console.error('Gitlab API error (4) - GET request with body', { url, method, body: requestBody, params })
305
280
  }
306
281
  // With or without body
307
282
  let response
283
+ const body = requestBody ? JSON.stringify(requestBody) : undefined
308
284
  try {
309
- response = await fetch(url, body && method !== 'GET' ? {
285
+ if(body && Buffer.byteLength(body) > 1024 * 100) {
286
+ console.warn('Gitlab API warning - body too big', Buffer.byteLength(body), 'bytes', { url, method, params })
287
+ }
288
+ response = await fetch(url, requestBody && method !== 'GET' ? {
310
289
  agent: this.getAgent(),
311
290
  method,
312
291
  headers,
313
- body: body ? JSON.stringify(body) : undefined
292
+ body,
314
293
  } : {
315
294
  agent: this.getAgent(),
316
295
  method,
@@ -318,12 +297,19 @@ export default class GitlabConnector implements StorageConnector {
318
297
  })
319
298
  } catch (e) {
320
299
  console.error('Gitlab API error (0)', e)
321
- throw new ApiError(`Gitlab API error (0): ${e.message} ${e.code} ${e.name} ${e.type}`, 500)
300
+ throw new ApiError(`Gitlab API error (0): ${e.message} ${e.text} ${e.code} ${e.name} ${e.type}`, 500)
322
301
  }
323
- let json: { message: string, error: string } | any
324
302
  // Handle the case when the server returns a non-JSON response (e.g. 400 Bad Request)
325
- const text = await response.text()
303
+ const text = await async function () {
304
+ try {
305
+ return await response.text()
306
+ } catch (e) {
307
+ console.error('Gitlab API error (6) - could not parse response', response.status, response.statusText, { url, method, body: requestBody, params }, e)
308
+ throw new ApiError(`Gitlab API error (6): response body not available. ${e.message}`, 500)
309
+ }
310
+ }()
326
311
  if (!response.ok) {
312
+ console.error('Gitlab API error (7) - response not ok', response.status, response.statusText, { url, method, body: requestBody, params, text: text })
327
313
  if (text.includes('A file with this name doesn\'t exist')) {
328
314
  throw new ApiError('Gitlab API error (5): Not Found', 404)
329
315
  } else if (response.status === 401 && this.getSessionToken(session).token?.refresh_token) {
@@ -354,7 +340,7 @@ export default class GitlabConnector implements StorageConnector {
354
340
  return await this.callApi(session, path, method, body as any, params)
355
341
  } else {
356
342
  const message = typeof refreshJson?.message === 'object' ? Object.entries(refreshJson.message).map(entry => entry.join(' ')).join(' ') : refreshJson?.message ?? refreshJson?.error ?? response.statusText
357
- console.error('Gitlab API error (2) - could not refresh token', response.status, response.statusText, {message}, 'refresh_token:', token?.refresh_token)
343
+ console.error('Gitlab API error (2) - could not refresh token', response.status, response.statusText, { message }, 'refresh_token:', token?.refresh_token)
358
344
  // Workaround for when the token is invalid
359
345
  // It happens often which is not normal (refresh token should last 6 months)
360
346
  this.logout(session)
@@ -362,26 +348,97 @@ export default class GitlabConnector implements StorageConnector {
362
348
  throw new ApiError(`Gitlab API error (2): ${message}`, response.status)
363
349
  }
364
350
  } else {
365
- const message = typeof json?.message === 'object' ? Object.entries(json.message).map(entry => entry.join(' ')).join(' ') : json?.message ?? json?.error ?? response.statusText
366
- console.error('Gitlab API error (1)', response.status, response.statusText, {url, method, body, params, text, message})
367
- throw new ApiError(`Gitlab API error (1): ${message}`, response.status)
351
+ const message = response.statusText
352
+ console.error('Gitlab API error (1)', response.status, response.statusText, { url, method, body: requestBody, params, text: text, message })
353
+ throw new ApiError(`Gitlab API error (1): ${message} (${text})`, response.status)
368
354
  }
369
355
  }
356
+ let json: { message: string, error: string } | any
370
357
  try {
371
358
  json = JSON.parse(text)
372
359
  } catch (e) {
373
- if(!response.ok) {
360
+ if (!response.ok) {
374
361
  // A real error
375
362
  throw e
376
363
  } else {
377
364
  // Useless error linked to the fact that the response is not JSON
378
- console.error('Gitlab API error (3) - could not parse response', response.status, response.statusText, {url, method, body, params, text})
365
+ console.error('Gitlab API error (3) - could not parse response', response.status, response.statusText, { url, method, body: requestBody, params, text: text })
379
366
  return text
380
367
  }
381
368
  }
382
369
  return json
383
370
  }
384
371
 
372
+ // Cache for projects path
373
+ private projectPathCache = new Map<string, string>()
374
+
375
+ async downloadRawFile(session: GitlabSession, projectId: string, filePath: string): Promise<Buffer> {
376
+ const token = this.getSessionToken(session).token?.access_token
377
+ const domain = this.options.domain
378
+ const branch = this.options.branch
379
+
380
+ // Get the project path with namespace
381
+ let pathWithNamespace = this.projectPathCache.get(projectId)
382
+ if (!pathWithNamespace) {
383
+ const metaUrl = `${domain}/api/v4/projects/${projectId}`
384
+ const metaRes = await fetch(metaUrl, {
385
+ agent: this.getAgent(),
386
+ headers: {
387
+ 'Authorization': `Bearer ${token}`,
388
+ },
389
+ })
390
+
391
+ const metaText = await metaRes.text()
392
+ if (!metaRes.ok) {
393
+ console.error('GitLab raw error (meta)', metaRes.status, metaRes.statusText, { metaUrl, metaText })
394
+ throw new ApiError(`GitLab raw error (meta): ${metaRes.statusText}`, metaRes.status)
395
+ }
396
+
397
+ try {
398
+ const meta = JSON.parse(metaText)
399
+ pathWithNamespace = meta.path_with_namespace
400
+ } catch (e) {
401
+ throw new ApiError('GitLab raw error (meta): could not parse project info', 500)
402
+ }
403
+
404
+ // Limit cache size, remove least recently used
405
+ this.projectPathCache.set(projectId, pathWithNamespace!)
406
+ if (this.projectPathCache.size > 500) {
407
+ this.projectPathCache.delete(this.projectPathCache.keys().next().value)
408
+ }
409
+ }
410
+
411
+ // Construct the raw URL
412
+ // GET /projects/:id/repository/files/:file_path/raw
413
+ const rawUrl = `${domain}/api/v4/projects/${encodeURIComponent(pathWithNamespace!)}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${branch}&access_token=${token}`
414
+ const fileRes = await fetch(rawUrl, {
415
+ agent: this.getAgent(),
416
+ })
417
+
418
+ const contentType = fileRes.headers.get('content-type')
419
+ if (contentType?.includes('text/html')) {
420
+ const html = await fileRes.text()
421
+ throw new ApiError('GitLab returned HTML instead of file (unauthorized or not found).', 401)
422
+ }
423
+
424
+ if (!fileRes.ok) {
425
+ const errText = await fileRes.text()
426
+ if (errText.includes('not found') || fileRes.status === 404) {
427
+ throw new ApiError('GitLab raw error (5): Not Found', 404)
428
+ }
429
+ console.error('GitLab raw error (1)', fileRes.status, fileRes.statusText, { rawUrl, errText })
430
+ throw new ApiError(`GitLab raw error (1): ${fileRes.statusText} (${errText})`, fileRes.status)
431
+ }
432
+
433
+ try {
434
+ const buffer = await fileRes.buffer()
435
+ return buffer
436
+ } catch (e) {
437
+ console.error('GitLab raw error (3): could not read buffer', e)
438
+ throw new ApiError('GitLab raw error (3): failed to read binary content', 500)
439
+ }
440
+ }
441
+
385
442
  private generateCodeVerifier() {
386
443
  return crypto.randomBytes(64).toString('base64')
387
444
  .replace(/\+/g, '-')
@@ -472,15 +529,15 @@ export default class GitlabConnector implements StorageConnector {
472
529
  */
473
530
  async setToken(session: GitlabSession, loginResult: any): Promise<void> {
474
531
  const sessionToken = this.getSessionToken(session)
475
- if(!loginResult.state || loginResult.state !== sessionToken?.state) {
532
+ if (!loginResult.state || loginResult.state !== sessionToken?.state) {
476
533
  this.logout(session)
477
534
  throw new ApiError('Invalid state', 401)
478
535
  }
479
- if(!sessionToken?.codeVerifier) {
536
+ if (!sessionToken?.codeVerifier) {
480
537
  this.logout(session)
481
538
  throw new ApiError('Missing code verifier', 401)
482
539
  }
483
- if(!sessionToken?.codeChallenge) {
540
+ if (!sessionToken?.codeChallenge) {
484
541
  this.logout(session)
485
542
  throw new ApiError('Missing code challenge', 401)
486
543
  }
@@ -558,7 +615,7 @@ export default class GitlabConnector implements StorageConnector {
558
615
  // }
559
616
  //}
560
617
  const userId = this.getSessionToken(session).userId
561
- if(!userId) {
618
+ if (!userId) {
562
619
  this.logout(session)
563
620
  throw new ApiError('Missing Gitlab user ID. User not logged in?', 401)
564
621
  }
@@ -574,16 +631,45 @@ export default class GitlabConnector implements StorageConnector {
574
631
  }))
575
632
  }
576
633
 
634
+ /**
635
+ * Read the website data
636
+ * The website data file is named `website.json` and the pages are named `page-{id}.json`
637
+ * The pages are stored in the `src` folder by default
638
+ */
577
639
  async readWebsite(session: GitlabSession, websiteId: string): Promise<WebsiteData> {
578
- const result = await this.callApi(session, `api/v4/projects/${websiteId}/repository/files/${WEBSITE_DATA_FILE}`, 'GET', null, {
579
- ref: this.options.branch,
580
- }) as any
581
- const { content } = result
582
- const contentDecoded = Buffer.from(content, 'base64').toString('utf8')
583
- const websiteData = JSON.parse(contentDecoded) as WebsiteData
584
- return websiteData
640
+ const websiteDataBuf = await this.downloadRawFile(session, websiteId, WEBSITE_DATA_FILE)
641
+ const websiteData = JSON.parse(websiteDataBuf.toString('utf8')) as GitlabWebsiteData | WebsiteData
642
+
643
+ // If the website pages are not in the main file, we need to read them
644
+ // This happens when the website was just created
645
+ // Let grapesjs create the pages in the frontend
646
+ if (!websiteData.pages) {
647
+ return websiteData as WebsiteData
648
+ }
649
+
650
+ // Load each page in parallel
651
+ const pages = await Promise.all(websiteData.pages.map(async (page: GitlabPage | Page) => {
652
+ if ((page as GitlabPage).isFile) {
653
+ const name = getPageSlug(page.name)
654
+ const fileName = (`${(getPageSlug(page.name))}-${page.id}`)
655
+ const filePath = `${WEBSITE_PAGES_FOLDER}/${fileName}.json`
656
+ const pageContent = await this.downloadRawFile(session, websiteId, filePath)
657
+ const res = JSON.parse(pageContent.toString('utf8')) as Page
658
+ return res
659
+ }
660
+ return page as Page
661
+ }))
662
+
663
+ // Read each page file if needed
664
+ return {
665
+ ...websiteData,
666
+ pages,
667
+ } as WebsiteData
585
668
  }
586
669
 
670
+ /**
671
+ * Create a new website, i.e. a new Gitlab repository with an empty website data file
672
+ */
587
673
  async createWebsite(session: GitlabSession, websiteMeta: WebsiteMetaFileContent): Promise<WebsiteId> {
588
674
  const project = await this.callApi(session, 'api/v4/projects/', 'POST', {
589
675
  name: this.options.repoPrefix + websiteMeta.name,
@@ -595,13 +681,76 @@ export default class GitlabConnector implements StorageConnector {
595
681
  return project.id
596
682
  }
597
683
 
684
+ /**
685
+ * Update the website data
686
+ * Split the website data into 1 file per page + 1 file for the website data itself
687
+ * Use gitlab batch API to create/update the files
688
+ */
598
689
  async updateWebsite(session: GitlabSession, websiteId: WebsiteId, websiteData: WebsiteData): Promise<void> {
599
- const project = await this.callApi(session, `api/v4/projects/${websiteId}/repository/files/${WEBSITE_DATA_FILE}`, 'PUT', {
690
+ const batchActions: GitlabAction[] = []
691
+
692
+ // List existing files in the pages folder
693
+ let existingFiles: string[] = []
694
+ try {
695
+ const files = await this.callApi(session, `api/v4/projects/${websiteId}/repository/tree`, 'GET', null, {
696
+ path: WEBSITE_PAGES_FOLDER,
697
+ recursive: false,
698
+ }) as any[]
699
+ existingFiles = files.map(file => file.path)
700
+ } catch (e) {
701
+ if (e.statusCode !== 404 && e.httpStatusCode !== 404) {
702
+ throw e
703
+ }
704
+ }
705
+
706
+ // Prepare actions for each page
707
+ const pages = websiteData.pages.map((page: Page) => {
708
+ const file_name = encodeURIComponent(`${(getPageSlug(page.name))}-${page.id}`)
709
+ const file_path = (`${WEBSITE_PAGES_FOLDER}/${file_name}.json`)
710
+ const content = JSON.stringify(page)
711
+
712
+ // Determine whether to create or update the file
713
+ if (existingFiles.includes(file_path)) {
714
+ batchActions.push({
715
+ action: 'update',
716
+ file_path,
717
+ content,
718
+ })
719
+ } else {
720
+ batchActions.push({
721
+ action: 'create',
722
+ file_path,
723
+ content,
724
+ })
725
+ }
726
+
727
+ return {
728
+ name: page.name,
729
+ id: page.id,
730
+ isFile: true,
731
+ }
732
+ })
733
+
734
+ // Prepare the main website data file
735
+ const websiteDataWithGitlabPages = {
736
+ ...websiteData,
737
+ pages,
738
+ } as GitlabWebsiteData
739
+
740
+ batchActions.push({
741
+ action: 'update',
742
+ file_path: WEBSITE_DATA_FILE,
743
+ content: JSON.stringify(websiteDataWithGitlabPages),
744
+ })
745
+
746
+ // Perform a single batch commit
747
+ const batch = {
600
748
  branch: this.options.branch,
601
749
  commit_message: 'Update website data from Silex',
602
- content: JSON.stringify(websiteData),
603
- id: websiteId,
604
- })
750
+ actions: batchActions,
751
+ }
752
+
753
+ await this.callApi(session, `api/v4/projects/${websiteId}/repository/commits`, 'POST', batch)
605
754
  }
606
755
 
607
756
  async deleteWebsite(session: GitlabSession, websiteId: WebsiteId): Promise<void> {
@@ -621,7 +770,6 @@ export default class GitlabConnector implements StorageConnector {
621
770
  // branch: this.options.branch,
622
771
  // commit_message: `Delete meta data of ${data.meta.name} (${websiteId}) from Silex`,
623
772
  // content: JSON.stringify(metaRepo),
624
- // file_path: this.options.metaRepoFile,
625
773
  //})
626
774
  }
627
775
 
@@ -641,12 +789,12 @@ export default class GitlabConnector implements StorageConnector {
641
789
  name: meta.name + ' Copy ' + new Date().toISOString().replace(/T.*/, '') + ' ' + Math.random().toString(36).substring(2, 4),
642
790
  })
643
791
  // Upload all files
644
- for(const file of files) {
645
- const path = encodeURIComponent(file)
646
- const content = await this.readFile(session, websiteId, path)
792
+ for (const file of files) {
793
+ const content = await this.readFile(session, websiteId, file)
647
794
  // From buffer to string
648
795
  const contentStr = content.toString('base64')
649
- switch(file) {
796
+ const path = encodeURIComponent(file)
797
+ switch (file) {
650
798
  case WEBSITE_DATA_FILE:
651
799
  await this.updateFile(session, newId, path, contentStr, true)
652
800
  break
@@ -686,68 +834,78 @@ export default class GitlabConnector implements StorageConnector {
686
834
  }
687
835
 
688
836
  async setWebsiteMeta(session: GitlabSession, websiteId: WebsiteId, websiteMeta: WebsiteMetaFileContent): Promise<void> {
689
- //// Load the meta repo data
690
- //const file = await this.callApi(session, `api/v4/projects/${this.getMetaRepoPath(session)}/repository/files/${this.options.metaRepoFile}`, 'GET', null, {
691
- // ref: this.options.branch,
692
- //})
693
- //const metaRepo = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')) as MetaRepoFileContent
694
- //// Update or create the website meta data
695
- //metaRepo.websites[websiteId] = {
696
- // updatedAt: new Date().toISOString(),
697
- // createdAt: metaRepo.websites[websiteId]?.createdAt ?? new Date().toISOString(),
698
- // meta: websiteMeta,
699
- //}
700
- //// Save the meta repo data
701
- //const project = await this.callApi(session, `api/v4/projects/${this.getMetaRepoPath(session)}/repository/files/${this.options.metaRepoFile}`, 'PUT', {
702
- // branch: this.options.branch,
703
- // commit_message: `Update website meta data of ${websiteMeta.name} (${websiteId}) from Silex`,
704
- // content: JSON.stringify(metaRepo),
705
- // file_path: this.options.metaRepoFile,
706
- //})
707
-
708
837
  // Rename the repo if needed
709
838
  const oldMeta = await this.getWebsiteMeta(session, websiteId)
710
- if(websiteMeta.name !== oldMeta.name) {
839
+ if (websiteMeta.name !== oldMeta.name) {
711
840
  await this.callApi(session, `api/v4/projects/${websiteId}`, 'PUT', {
712
841
  name: this.options.repoPrefix + websiteMeta.name,
713
842
  })
714
843
  }
715
- //// Update the metadata file
716
- //await this.callApi(session, `api/v4/projects/${websiteId}/repository/files/${WEBSITE_META_DATA_FILE}`, 'PUT', {
717
- // branch: this.options.branch,
718
- // commit_message: 'Update website meta data from Silex',
719
- // content: JSON.stringify(websiteMeta),
720
- // file_path: WEBSITE_META_DATA_FILE,
721
- // id: websiteId,
722
- //})
723
844
  }
724
845
 
725
- async writeAssets(session: GitlabSession, websiteId: string, files: ConnectorFile[], status?: StatusCallback | undefined): Promise<void> {
726
- status && await status({ message: 'in progress...', status: JobStatus.IN_PROGRESS })
727
- // For each file
728
- for (const file of files) {
729
- // Convert to base64
730
- const content = (await contentToBuffer(file.content)).toString('base64')
731
- const path = this.getAssetPath(file.path)
846
+ async writeAssets(session: GitlabSession, websiteId: string, files: ConnectorFile[], status?: StatusCallback): Promise<void> {
847
+ status && await status({ message: `Preparing ${files.length} files`, status: JobStatus.IN_PROGRESS })
848
+
849
+ // List all the files in the repo
850
+ const existingPaths = new Set<string>()
851
+ let page = 1
852
+ let keepGoing = true
853
+ while (keepGoing) {
854
+ const tree = await this.callApi(session, `api/v4/projects/${websiteId}/repository/tree`, 'GET', null, {
855
+ recursive: true,
856
+ per_page: 100,
857
+ page,
858
+ })
859
+ for (const f of tree) {
860
+ if (f.type === 'blob') existingPaths.add(f.path)
861
+ }
862
+ keepGoing = tree.length === 100
863
+ page++
864
+ }
865
+
866
+ // Split the files into chunks to avoid the number of files limit
867
+ const chunks: ConnectorFile[][] = []
868
+ for (let i = 0; i < files.length; i += MAX_BATCH_UPLOAD_SIZE) {
869
+ chunks.push(files.slice(i, i + MAX_BATCH_UPLOAD_SIZE))
870
+ }
871
+
872
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
873
+ const chunk = chunks[chunkIndex]
874
+ status && await status({ message: `Uploading batch ${chunkIndex + 1}/${chunks.length}`, status: JobStatus.IN_PROGRESS })
875
+
876
+ // Create the actions for the batch
877
+ const actions = await Promise.all(
878
+ chunk.map(async (file) => {
879
+ const content = (await contentToBuffer(file.content)).toString('base64')
880
+ const file_path = this.getAssetPath(file.path, false)
881
+ const actionType = existingPaths.has(file_path) ? 'update' : 'create'
882
+ return {
883
+ action: actionType,
884
+ file_path,
885
+ content,
886
+ encoding: 'base64',
887
+ }
888
+ })
889
+ ) as GitlabAction[]
732
890
 
733
891
  try {
734
- await this.updateFile(session, websiteId, path, content, true)
892
+ await this.callApi(session, `api/v4/projects/${websiteId}/repository/commits`, 'POST', {
893
+ branch: 'main',
894
+ commit_message: `Batch update assets (${chunkIndex + 1}/${chunks.length})`,
895
+ actions,
896
+ })
735
897
  } catch (e) {
736
- // If the file does not exist, create it
737
- if (e.statusCode === 404 || e.httpStatusCode === 404 || e.message.endsWith('A file with this name doesn\'t exist')) {
738
- await this.createFile(session, websiteId, path, content, true)
739
- } else {
740
- status && await status({ message: 'Error', status: JobStatus.ERROR })
741
- throw e
742
- }
898
+ console.error(`Batch ${chunkIndex + 1} failed`, e)
899
+ status && await status({ message: `Error in batch ${chunkIndex + 1}`, status: JobStatus.ERROR })
900
+ throw e
743
901
  }
744
902
  }
745
- status && await status({ message: 'Successfull', status: JobStatus.SUCCESS })
903
+
904
+ status && await status({ message: 'All files uploaded successfully', status: JobStatus.SUCCESS })
746
905
  }
747
906
 
748
907
  async readAsset(session: GitlabSession, websiteId: string, fileName: string): Promise<ConnectorFileContent> {
749
- // Remove leading slash
750
- const finalPath = this.getAssetPath(fileName)
908
+ const finalPath = this.getAssetPath(fileName, false)
751
909
  return this.readFile(session, websiteId, finalPath)
752
910
  }
753
911
 
@@ -762,4 +920,32 @@ export default class GitlabConnector implements StorageConnector {
762
920
  })),
763
921
  })
764
922
  }
923
+
924
+ /*
925
+ * Get the meta repo path for the current user
926
+ * The meta repo contains a JSON file which contains the list of websites
927
+ */
928
+ //private getMetaRepoPath(session: GitlabSession): string {
929
+ // if(!this.getSessionToken(session).username) throw new ApiError('Missing Gitlab user ID. User not logged in?', 401)
930
+ // return encodeURIComponent(`${this.getSessionToken(session).username}/${this.options.metaRepo}`)
931
+ //}
932
+
933
+ ///**
934
+ // * Initialize the storage with a meta repo
935
+ // */
936
+ //private async initStorage(session: GitlabSession): Promise<void> {
937
+ // // Create the meta repo
938
+ // try {
939
+ // const project = await this.callApi(session, 'api/v4/projects/', 'POST', {
940
+ // name: this.options.metaRepo,
941
+ // }) as any
942
+ // return this.createFile(session, this.getMetaRepoPath(session), this.options.metaRepoFile, JSON.stringify({
943
+ // websites: {}
944
+ // } as MetaRepoFileContent))
945
+ // } catch (e) {
946
+ // console.error('Could not init storage', e.statusCode, e.httpStatusCode, e)
947
+ // throw e
948
+ // }
949
+ //}
950
+
765
951
  }