@silexlabs/silex 3.0.7 → 3.0.9

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.
@@ -31,6 +31,9 @@ import { getPageSlug } from '../../page'
31
31
  * @see https://docs.gitlab.com/ee/api/oauth2.html
32
32
  */
33
33
 
34
+ const MAX_BATCH_UPLOAD_SIZE = 100
35
+ const MAX_BODY_SIZE_KB = 8 * 1000 * 1024 // 8MB (note that 10 MB PNG → becomes ~13.3 MB → ❌ often too big for Gitlab)
36
+
34
37
  export interface GitlabOptions {
35
38
  clientId: string
36
39
  clientSecret: string
@@ -265,7 +268,7 @@ export default class GitlabConnector implements StorageConnector {
265
268
  /**
266
269
  * Call the Gitlab API with the user's token and handle errors
267
270
  */
268
- 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> {
271
+ 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> {
269
272
  const token = this.getSessionToken(session).token
270
273
  const tokenParam = token ? `access_token=${token.access_token}&` : ''
271
274
  const paramsStr = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent((v as any).toString())}`).join('&')
@@ -273,17 +276,22 @@ export default class GitlabConnector implements StorageConnector {
273
276
  const headers = {
274
277
  'Content-Type': 'application/json',
275
278
  }
276
- if (method === 'GET' && body) {
277
- console.error('Gitlab API error (4) - GET request with body', { url, method, body, params })
279
+ if (method === 'GET' && requestBody) {
280
+ console.error('Gitlab API error (4) - GET request with body', { url, method, body: requestBody, params })
278
281
  }
279
282
  // With or without body
280
283
  let response
284
+ const body = requestBody ? JSON.stringify(requestBody) : undefined
281
285
  try {
282
- response = await fetch(url, body && method !== 'GET' ? {
286
+ if(body && Buffer.byteLength(body) > MAX_BODY_SIZE_KB * 1024) {
287
+ // TODO: warn the end user
288
+ console.warn('Gitlab API warning - body too big', Buffer.byteLength(body), 'bytes', { url, method, params })
289
+ }
290
+ response = await fetch(url, requestBody && method !== 'GET' ? {
283
291
  agent: this.getAgent(),
284
292
  method,
285
293
  headers,
286
- body: body ? JSON.stringify(body) : undefined
294
+ body,
287
295
  } : {
288
296
  agent: this.getAgent(),
289
297
  method,
@@ -298,11 +306,12 @@ export default class GitlabConnector implements StorageConnector {
298
306
  try {
299
307
  return await response.text()
300
308
  } catch (e) {
301
- console.error('Gitlab API error (6) - could not parse response', response.status, response.statusText, { url, method, body, params }, e)
309
+ console.error('Gitlab API error (6) - could not parse response', response.status, response.statusText, { url, method, body: requestBody, params }, e)
302
310
  throw new ApiError(`Gitlab API error (6): response body not available. ${e.message}`, 500)
303
311
  }
304
312
  }()
305
313
  if (!response.ok) {
314
+ console.error('Gitlab API error (7) - response not ok', response.status, response.statusText, { url, method, body: requestBody, params, text: text })
306
315
  if (text.includes('A file with this name doesn\'t exist')) {
307
316
  throw new ApiError('Gitlab API error (5): Not Found', 404)
308
317
  } else if (response.status === 401 && this.getSessionToken(session).token?.refresh_token) {
@@ -342,7 +351,7 @@ export default class GitlabConnector implements StorageConnector {
342
351
  }
343
352
  } else {
344
353
  const message = response.statusText
345
- console.error('Gitlab API error (1)', response.status, response.statusText, { url, method, body, params, text: text, message })
354
+ console.error('Gitlab API error (1)', response.status, response.statusText, { url, method, body: requestBody, params, text: text, message })
346
355
  throw new ApiError(`Gitlab API error (1): ${message} (${text})`, response.status)
347
356
  }
348
357
  }
@@ -355,62 +364,24 @@ export default class GitlabConnector implements StorageConnector {
355
364
  throw e
356
365
  } else {
357
366
  // Useless error linked to the fact that the response is not JSON
358
- console.error('Gitlab API error (3) - could not parse response', response.status, response.statusText, { url, method, body, params, text: text })
367
+ console.error('Gitlab API error (3) - could not parse response', response.status, response.statusText, { url, method, body: requestBody, params, text: text })
359
368
  return text
360
369
  }
361
370
  }
362
371
  return json
363
372
  }
364
373
 
365
- // Cache for projects path
366
- private projectPathCache = new Map<string, string>()
367
-
368
374
  async downloadRawFile(session: GitlabSession, projectId: string, filePath: string): Promise<Buffer> {
369
375
  const token = this.getSessionToken(session).token?.access_token
370
376
  const domain = this.options.domain
371
377
  const branch = this.options.branch
372
378
 
373
- // Get the project path with namespace
374
- let pathWithNamespace = this.projectPathCache.get(projectId)
375
- if (!pathWithNamespace) {
376
- const metaUrl = `${domain}/api/v4/projects/${projectId}`
377
- const metaRes = await fetch(metaUrl, {
378
- agent: this.getAgent(),
379
- headers: {
380
- 'Authorization': `Bearer ${token}`,
381
- },
382
- })
383
- console.log('metaRes', metaRes.status, metaRes.statusText, { metaUrl })
384
-
385
- const metaText = await metaRes.text()
386
- if (!metaRes.ok) {
387
- console.error('GitLab raw error (meta)', metaRes.status, metaRes.statusText, { metaUrl, metaText })
388
- throw new ApiError(`GitLab raw error (meta): ${metaRes.statusText}`, metaRes.status)
389
- }
390
- console.log('metaText', metaText)
391
-
392
- try {
393
- const meta = JSON.parse(metaText)
394
- pathWithNamespace = meta.path_with_namespace
395
- } catch (e) {
396
- throw new ApiError('GitLab raw error (meta): could not parse project info', 500)
397
- }
398
- console.log('pathWithNamespace', pathWithNamespace)
399
-
400
- // Limit cache size, remove least recently used
401
- this.projectPathCache.set(projectId, pathWithNamespace!)
402
- if (this.projectPathCache.size > 500) {
403
- this.projectPathCache.delete(this.projectPathCache.keys().next().value)
404
- }
405
- }
406
-
407
379
  // Construct the raw URL
408
380
  // GET /projects/:id/repository/files/:file_path/raw
409
- const rawUrl = `${domain}/api/v4/projects/${encodeURIComponent(pathWithNamespace!)}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${branch}&access_token=${token}`
381
+ const rawUrl = `${domain}/api/v4/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${branch}&access_token=${token}`
410
382
  const fileRes = await fetch(rawUrl, {
411
383
  agent: this.getAgent(),
412
384
  })
413
- console.log('fileRes', fileRes.status, fileRes.statusText, { rawUrl, token })
414
385
 
415
386
  const contentType = fileRes.headers.get('content-type')
416
387
  if (contentType?.includes('text/html')) {
@@ -426,12 +397,9 @@ export default class GitlabConnector implements StorageConnector {
426
397
  console.error('GitLab raw error (1)', fileRes.status, fileRes.statusText, { rawUrl, errText })
427
398
  throw new ApiError(`GitLab raw error (1): ${fileRes.statusText} (${errText})`, fileRes.status)
428
399
  }
429
- console.log('fileRes ok', fileRes.status, fileRes.statusText)
430
400
 
431
401
  try {
432
- console.log('fileRes buffer', fileRes.headers.get('content-type'), fileRes.headers.get('content-length'))
433
402
  const buffer = await fileRes.buffer()
434
- console.log('fileRes buffer', buffer.length)
435
403
  return buffer
436
404
  } catch (e) {
437
405
  console.error('GitLab raw error (3): could not read buffer', e)
@@ -843,27 +811,66 @@ export default class GitlabConnector implements StorageConnector {
843
811
  }
844
812
  }
845
813
 
846
- async writeAssets(session: GitlabSession, websiteId: string, files: ConnectorFile[], status?: StatusCallback | undefined): Promise<void> {
847
- status && await status({ message: 'in progress...', status: JobStatus.IN_PROGRESS })
848
- // For each file
849
- for (const file of files) {
850
- // Convert to base64
851
- const content = (await contentToBuffer(file.content)).toString('base64')
852
- const path = this.getAssetPath(file.path)
814
+ async writeAssets(session: GitlabSession, websiteId: string, files: ConnectorFile[], status?: StatusCallback): Promise<void> {
815
+ status && await status({ message: `Preparing ${files.length} files`, status: JobStatus.IN_PROGRESS })
816
+
817
+ // List all the files in the repo
818
+ const existingPaths = new Set<string>()
819
+ let page = 1
820
+ let keepGoing = true
821
+ while (keepGoing) {
822
+ const tree = await this.callApi(session, `api/v4/projects/${websiteId}/repository/tree`, 'GET', null, {
823
+ recursive: true,
824
+ per_page: 100,
825
+ page,
826
+ })
827
+ for (const f of tree) {
828
+ if (f.type === 'blob') existingPaths.add(f.path)
829
+ }
830
+ keepGoing = tree.length === 100
831
+ page++
832
+ }
833
+
834
+ // Split the files into chunks to avoid the number of files limit
835
+ const chunks: ConnectorFile[][] = []
836
+ for (let i = 0; i < files.length; i += MAX_BATCH_UPLOAD_SIZE) {
837
+ chunks.push(files.slice(i, i + MAX_BATCH_UPLOAD_SIZE))
838
+ }
853
839
 
840
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
841
+ const chunk = chunks[chunkIndex]
842
+ status && await status({ message: `Batch ${chunkIndex + 1}/${chunks.length}: Downloading assets`, status: JobStatus.IN_PROGRESS })
843
+
844
+ // Create the actions for the batch
845
+ const actions = await Promise.all(
846
+ chunk.map(async (file) => {
847
+ const content = (await contentToBuffer(file.content)).toString('base64')
848
+ const file_path = this.getAssetPath(file.path, false)
849
+ const actionType = existingPaths.has(file_path) ? 'update' : 'create'
850
+ return {
851
+ action: actionType,
852
+ file_path,
853
+ content,
854
+ encoding: 'base64',
855
+ }
856
+ })
857
+ ) as GitlabAction[]
858
+
859
+ status && await status({ message: `Batch ${chunkIndex + 1}/${chunks.length}: Uploading assets`, status: JobStatus.IN_PROGRESS })
854
860
  try {
855
- await this.updateFile(session, websiteId, path, content, true)
861
+ await this.callApi(session, `api/v4/projects/${websiteId}/repository/commits`, 'POST', {
862
+ branch: 'main',
863
+ commit_message: `Batch update assets (${chunkIndex + 1}/${chunks.length})`,
864
+ actions,
865
+ })
856
866
  } catch (e) {
857
- // If the file does not exist, create it
858
- if (e.statusCode === 404 || e.httpStatusCode === 404 || e.message.endsWith('A file with this name doesn\'t exist')) {
859
- await this.createFile(session, websiteId, path, content, true)
860
- } else {
861
- status && await status({ message: 'Error', status: JobStatus.ERROR })
862
- throw e
863
- }
867
+ console.error(`Batch ${chunkIndex + 1} failed`, e)
868
+ status && await status({ message: `Error in batch ${chunkIndex + 1}`, status: JobStatus.ERROR })
869
+ throw e
864
870
  }
865
871
  }
866
- status && await status({ message: 'Successfull', status: JobStatus.SUCCESS })
872
+
873
+ status && await status({ message: 'All files uploaded successfully', status: JobStatus.SUCCESS })
867
874
  }
868
875
 
869
876
  async readAsset(session: GitlabSession, websiteId: string, fileName: string): Promise<ConnectorFileContent> {
@@ -73,13 +73,19 @@ export default class GitlabHostingConnector extends GitlabConnector implements H
73
73
  - if: '$CI_PIPELINE_SOURCE == "trigger"'
74
74
  `
75
75
  try {
76
+ job.message = `Checking if ${pathYml} needs to be created or updated...`
77
+ job.logs[0].push(job.message)
76
78
  const originalCi = await this.readFile(session, websiteId, pathYml)
77
79
  if(originalCi.toString().startsWith(SILEX_OVERWRITE_NOTICE)) {
80
+ job.message = `Updating ${pathYml}...`
81
+ job.logs[0].push(job.message)
78
82
  await this.updateFile(session, websiteId, pathYml, contentYml)
79
83
  }
80
84
  } catch (e) {
81
85
  // If the file .gitlab-ci.yml does not exist, create it, otherwise do nothing (do not overwriting existing one)
82
86
  if (e.statusCode === 404 || e.httpStatusCode === 404 || e.message.endsWith('A file with this name doesn\'t exist')) {
87
+ job.message = `Creating ${pathYml}...`
88
+ job.logs[0].push(job.message)
83
89
  await this.createFile(session, websiteId, pathYml, contentYml)
84
90
  } else {
85
91
  jobError(job.jobId, e.message)
@@ -127,12 +127,12 @@ export default function (config: ServerConfig): Router {
127
127
  const fileWithContent = file as ClientSideFileWithContent
128
128
  const fileWithSrc = file as ClientSideFileWithSrc
129
129
  if(!fileWithContent.content && !fileWithSrc.src) throw new PublicationError('Missing content or src in file', 400)
130
- const src = fileWithSrc.src
130
+ const content = fileWithContent.content ?? await storage.readAsset(session, websiteId, fileWithSrc.src)
131
131
  return {
132
132
  // Destination
133
133
  path: file.permalink ?? file.path,
134
134
  // Content
135
- content: fileWithContent.content ?? await storage.readAsset(session, websiteId, fileWithSrc.src),
135
+ content,
136
136
  }
137
137
  }))
138
138