UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

1,152 lines (1,051 loc) 39.4 kB
/* * Silex website builder, free/libre no-code tool for makers. * Copyright (c) 2023 lexoyo and Silex Labs foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { API_CONNECTOR_LOGIN_CALLBACK, API_CONNECTOR_PATH, API_PATH, WEBSITE_DATA_FILE, WEBSITE_PAGES_FOLDER } from '../../constants' import { ServerConfig } from '../../server/config' import { ConnectorFile, ConnectorFileContent, StatusCallback, StorageConnector, contentToBuffer, contentToString, toConnectorData } from '../../server/connectors/connectors' import { ApiError, ConnectorType, ConnectorUser, WebsiteData, WebsiteId, WebsiteMeta, WebsiteMetaFileContent, JobStatus, Page } from '../../types' import fetch from 'node-fetch' import crypto, { createHash } from 'crypto' import { join } from 'path' import { Agent } from 'https' import { getPageSlug } from '../../page' import e from 'express' import { fork } from 'child_process' /** * Gitlab connector * @fileoverview Gitlab connector for Silex, connect to the user's Gitlab account to store websites * @see https://docs.gitlab.com/ee/api/oauth2.html */ const MAX_BATCH_UPLOAD_SIZE = 100 const MAX_BODY_SIZE_KB = 8 * 1000 * 1024 // 8MB (note that 10 MB PNG → becomes ~13.3 MB → ❌ often too big for Gitlab) const WEBSITE_DATA_FILE_FORMAT_VERSION = '1.0.0' export interface GitlabOptions { clientId: string clientSecret: string branch: string assetsFolder: string repoPrefix: string scope: string domain: string timeOut: number //metaRepo: string //metaRepoFile: string } export interface GitlabToken { state?: string codeVerifier?: string codeChallenge?: string token?: { access_token: string token_type: string expires_in: number refresh_token: string created_at: number id_token: string scope: string } userId?: number username?: string } export type GitlabSession = Record<string, GitlabToken> interface GitlabAction { action: 'create' | 'delete' | 'move' | 'update' | 'cherry-pick' file_path?: string content?: string commit_id?: string encoding?: 'base64' | 'text' } interface GitlabWriteFile { branch: string commit_message: string id?: string actions?: GitlabAction[] content?: string file_path?: string encoding?: 'base64' | 'text' } interface GitlabGetToken { grant_type: 'authorization_code' client_id: string client_secret: string code: string redirect_uri: string code_verifier: string } interface GitlabWebsiteName { name: string } interface GitlabCreateBranch { branch: string ref: string } interface GitlabGetTags { per_page?: number } interface GitlabCreateTag { tag_name: string ref: string message: string } interface GitlabFetchCommits { ref_name: string since: string } interface GitlabPage { id: string name: string isFile: true } interface GitlabWebsiteData { fileFormatVersion: string pages: GitlabPage[] } // interface MetaRepoFileContent { // websites: { // [websiteId: string]: { // meta: WebsiteMetaFileContent, // createdAt: string, // updatedAt: string, // } // } // } const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="1000" height="963.197" viewBox="0 0 1000 963.197" version="1.1" id="svg85"> <sodipodi:namedview id="namedview87" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" showgrid="false" inkscape:zoom="1" inkscape:cx="991.5" inkscape:cy="964.5" inkscape:window-width="1126" inkscape:window-height="895" inkscape:window-x="774" inkscape:window-y="12" inkscape:window-maximized="0" inkscape:current-layer="svg85" /> <defs id="defs74"> <style id="style72">.cls-1{fill:#e24329;}.cls-2{fill:#fc6d26;}.cls-3{fill:#fca326;}</style> </defs> <g id="LOGO" transform="matrix(5.2068817,0,0,5.2068817,-489.30756,-507.76085)"> <path class="cls-1" d="m 282.83,170.73 -0.27,-0.69 -26.14,-68.22 a 6.81,6.81 0 0 0 -2.69,-3.24 7,7 0 0 0 -8,0.43 7,7 0 0 0 -2.32,3.52 l -17.65,54 h -71.47 l -17.65,-54 a 6.86,6.86 0 0 0 -2.32,-3.53 7,7 0 0 0 -8,-0.43 6.87,6.87 0 0 0 -2.69,3.24 L 97.44,170 l -0.26,0.69 a 48.54,48.54 0 0 0 16.1,56.1 l 0.09,0.07 0.24,0.17 39.82,29.82 19.7,14.91 12,9.06 a 8.07,8.07 0 0 0 9.76,0 l 12,-9.06 19.7,-14.91 40.06,-30 0.1,-0.08 a 48.56,48.56 0 0 0 16.08,-56.04 z" id="path76" /> <path class="cls-2" d="m 282.83,170.73 -0.27,-0.69 a 88.3,88.3 0 0 0 -35.15,15.8 L 190,229.25 c 19.55,14.79 36.57,27.64 36.57,27.64 l 40.06,-30 0.1,-0.08 a 48.56,48.56 0 0 0 16.1,-56.08 z" id="path78" /> <path class="cls-3" d="m 153.43,256.89 19.7,14.91 12,9.06 a 8.07,8.07 0 0 0 9.76,0 l 12,-9.06 19.7,-14.91 c 0,0 -17.04,-12.89 -36.59,-27.64 -19.55,14.75 -36.57,27.64 -36.57,27.64 z" id="path80" /> <path class="cls-2" d="M 132.58,185.84 A 88.19,88.19 0 0 0 97.44,170 l -0.26,0.69 a 48.54,48.54 0 0 0 16.1,56.1 l 0.09,0.07 0.24,0.17 39.82,29.82 c 0,0 17,-12.85 36.57,-27.64 z" id="path82" /> </g> </svg>` const encodedSvg = encodeURIComponent(svg) const ICON = '/assets/gitlab.png' export function computeGitBlobSha(content: string, binary: boolean): string { const contentBuffer = binary ? Buffer.from(content, 'base64') // for binary files : Buffer.from(content, 'utf8') // for text files const header = `blob ${contentBuffer.length}\0` const full = Buffer.concat([Buffer.from(header), contentBuffer as Buffer]) return crypto.createHash('sha1').update(full).digest('hex') } function sanitizeGitlabPath(name: string): string { return name .normalize('NFD') // separate accents .replace(/[\u0300-\u036f]/g, '') // remove accents .replace(/[^a-zA-Z0-9._-]/g, '-') // only allow allowed characters .replace(/^[-_.]+/, '') // no starting '-', '_' or '.' .replace(/[-_.]+$/, '') // no ending '-', '_' or '.' .replace(/\.git$|\.atom$/i, '') // forbidden endings .toLowerCase() } export default class GitlabConnector implements StorageConnector { connectorId = 'gitlab' connectorType = ConnectorType.STORAGE displayName = 'GitLab' icon = ICON disableLogout = false color = '#2B1B63' background = 'rgba(252, 109, 38, 0.2)' options: GitlabOptions constructor(private config: ServerConfig, opts: Partial<GitlabOptions>) { this.options = { branch: 'main', assetsFolder: 'assets', //metaRepo: 'silex-meta', //metaRepoFile: 'websites.json', repoPrefix: 'silex_', scope: 'api', // 'api+read_api+read_user+read_repository+write_repository+email+sudo+profile+openid' ...opts, } as GitlabOptions if (!this.options.clientId) throw new Error('Missing Gitlab client ID') if (!this.options.clientSecret) throw new Error('Missing Gitlab client secret') if (!this.options.domain) throw new Error('Missing Gitlab domain') if (!this.options.timeOut) this.options.timeOut = 15000 /* default value */ } // ** // Convenience methods for the Gitlab API private getAssetPath(path: string, encode = true): string { const resolvedPath = join(this.options.assetsFolder, path) if (encode) return encodeURIComponent(resolvedPath) return resolvedPath } isUsingOfficialInstance(): boolean { const gitlabDomainRegexp = /(^|\b)(gitlab\.com)($|\b)/ return gitlabDomainRegexp.test(this.options.domain) } async createFile(session: GitlabSession, websiteId: WebsiteId, path: string, content: string, isBase64 = false): Promise<void> { // Remove leading slash const safePath = path.replace(/^\//, '') const encodePath = decodeURIComponent(path) return this.callApi({ session, path: `api/v4/projects/${websiteId}/repository/files/${safePath}`, method: 'POST', requestBody: { id: websiteId, branch: this.options.branch, content, commit_message: `Create file ${encodePath} from Silex`, encoding: isBase64 ? 'base64' : undefined, } }) } async updateFile(session: GitlabSession, websiteId: WebsiteId, path: string, content: string, isBase64 = false): Promise<void> { // Remove leading slash const safePath = path.replace(/^\//, '') const encodePath = decodeURIComponent(path) return this.callApi({ session, path: `api/v4/projects/${websiteId}/repository/files/${safePath}`, method: 'PUT', requestBody: { id: websiteId, branch: this.options.branch, content: await contentToString(content), commit_message: `Update website asset ${encodePath} from Silex`, encoding: isBase64 ? 'base64' : undefined, } }) } async readFile(session: GitlabSession, websiteId: string, fileName: string): Promise<Buffer> { // Remove leading slash const safePath = fileName.replace(/^\//, '') return this.downloadRawFile(session, websiteId, safePath) } /** * Call the Gitlab API with the user's token and handle errors */ async callApi({ session, path, method = 'GET', requestBody = null, params = {}, responseHeaders = {}, // Will get the response heaaders }: { session: GitlabSession, path: string, method?: 'POST' | 'GET' | 'PUT' | 'DELETE', requestBody?: GitlabWriteFile | GitlabGetToken | GitlabWebsiteName | GitlabCreateBranch | GitlabGetTags | GitlabCreateTag | GitlabFetchCommits | null, params?: any, responseHeaders?: any, }): Promise<any> { const token = this.getSessionToken(session).token const tokenParam = token ? `access_token=${token.access_token}&` : '' const paramsStr = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent((v as any).toString())}`).join('&') const url = `${this.options.domain}/${path}?${tokenParam}${paramsStr}` const headers = { 'Content-Type': 'application/json', } if (method === 'GET' && requestBody) { console.error('Gitlab API error (4) - GET request with body', { url, method, body: requestBody, params }) } // With or without body let response const body = requestBody ? JSON.stringify(requestBody) : undefined try { if(body && Buffer.byteLength(body) > MAX_BODY_SIZE_KB * 1024) { // TODO: warn the end user console.warn('Gitlab API warning - body too big', Buffer.byteLength(body), 'bytes', { url, method, params }) } response = await fetch(url, requestBody && method !== 'GET' ? { agent: this.getAgent(), method, headers, body, } : { agent: this.getAgent(), method, headers, }) } catch (e) { console.error('Gitlab API error (0)', e) throw new ApiError(`Gitlab API error (0): ${e.message} ${e.text} ${e.code} ${e.name} ${e.type}`, 500) } // Pass the response headers to the caller response.headers.forEach((value, name) => responseHeaders[name] = value) // Handle the case when the server returns a non-JSON response (e.g. 400 Bad Request) const text = await async function () { try { return await response.text() } catch (e) { console.error('Gitlab API error (6) - could not parse response', response.status, response.statusText, { url, method, body: requestBody, params }, e) throw new ApiError(`Gitlab API error (6): response body not available. ${e.message}`, 500) } }() if (!response.ok) { console.error('Gitlab API error (7) - response not ok', response.status, response.statusText, { url, method, body: requestBody, params, text: text }) if (text.includes('A file with this name doesn\'t exist')) { throw new ApiError('Gitlab API error (5): Not Found', 404) } else if (response.status === 401 && this.getSessionToken(session).token?.refresh_token) { // Refresh the token const token = this.getSessionToken(session).token const body = { grant_type: 'refresh_token', refresh_token: token?.refresh_token, client_id: this.options.clientId, client_secret: this.options.clientSecret, } const response = await fetch(this.options.domain + '/oauth/token', { agent: this.getAgent(), method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(body) }) const refreshJson = await response.json() if (response.ok) { this.setSessionToken(session, { token: { ...token, ...refreshJson, }, } as GitlabToken) return await this.callApi({ session, path, method, requestBody: body as any, params, responseHeaders, }) } else { const message = typeof refreshJson?.message === 'object' ? Object.entries(refreshJson.message).map(entry => entry.join(' ')).join(' ') : refreshJson?.message ?? refreshJson?.error ?? response.statusText console.error('Gitlab API error (2) - could not refresh token', response.status, response.statusText, { message }, 'refresh_token:', token?.refresh_token) // Workaround for when the token is invalid // It happens often which is not normal (refresh token should last 6 months) this.logout(session) // Notify the user throw new ApiError(`Gitlab API error (2): ${message}`, response.status) } } else { const message = response.statusText console.error('Gitlab API error (1)', response.status, response.statusText, { url, method, body: requestBody, params, text: text, message }) throw new ApiError(`Gitlab API error (1): ${message} (${text})`, response.status) } } let json: { message: string, error: string } | any try { json = JSON.parse(text) } catch (e) { if (!response.ok) { // A real error throw e } else { // Useless error linked to the fact that the response is not JSON console.error('Gitlab API error (3) - could not parse response', response.status, response.statusText, { url, method, body: requestBody, params, text: text }) return text } } return json } async downloadRawFile(session: GitlabSession, projectId: string, filePath: string): Promise<Buffer> { const token = this.getSessionToken(session).token?.access_token const domain = this.options.domain const branch = this.options.branch // Construct the raw URL // GET /projects/:id/repository/files/:file_path/raw const rawUrl = `${domain}/api/v4/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${branch}&access_token=${token}` const fileRes = await fetch(rawUrl, { agent: this.getAgent(), }) const contentType = fileRes.headers.get('content-type') if (contentType?.includes('text/html')) { const html = await fileRes.text() throw new ApiError('GitLab returned HTML instead of file (unauthorized or not found).', 401) } if (!fileRes.ok) { const errText = await fileRes.text() if (errText.includes('not found') || fileRes.status === 404) { throw new ApiError('GitLab raw error (5): Not Found', 404) } console.error('GitLab raw error (1)', fileRes.status, fileRes.statusText, { rawUrl, errText }) throw new ApiError(`GitLab raw error (1): ${fileRes.statusText} (${errText})`, fileRes.status) } try { const buffer = await fileRes.buffer() return buffer } catch (e) { console.error('GitLab raw error (3): could not read buffer', e) throw new ApiError('GitLab raw error (3): failed to read binary content', 500) } } private generateCodeVerifier() { return crypto.randomBytes(64).toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '') .substr(0, 128) } private async generateCodeChallenge(verifier) { const hashed = createHash('sha256').update(verifier).digest() let base64Url = hashed.toString('base64') // Replace '+' with '-', '/' with '_', and remove '=' base64Url = base64Url.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') return base64Url } private getRedirect() { const params = `connectorId=${this.connectorId}&type=${this.connectorType}` return `${this.config.url}${API_PATH}${API_CONNECTOR_PATH}${API_CONNECTOR_LOGIN_CALLBACK}?${params}` } // Force IPv4 when running locally private getAgent(): Agent | undefined { if (this.config.url.startsWith('http://localhost')) { return new Agent({ family: 4, }) } return undefined } /** * Get the OAuth URL to redirect the user to * The URL should look like * https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=STATE&scope=REQUESTED_SCOPES&code_challenge=CODE_CHALLENGE&code_challenge_method=S256 * OAuth2 Step #1 from https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-with-proof-key-for-code-exchange-pkce */ async getOAuthUrl(session: GitlabSession): Promise<string> { const redirect_uri = encodeURIComponent(this.getRedirect()) const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) const codeVerifier = this.generateCodeVerifier() // Create the code challenge const codeChallenge = await this.generateCodeChallenge(codeVerifier) // Store the code verifier and code challenge in the session this.setSessionToken(session, { ...this.getSessionToken(session), state, codeVerifier, codeChallenge, }) return `${this.options.domain}/oauth/authorize?client_id=${this.options.clientId}&redirect_uri=${redirect_uri}&response_type=code&state=${this.getSessionToken(session).state}&scope=${this.options.scope}&code_challenge=${codeChallenge}&code_challenge_method=S256` } getSessionToken(session: GitlabSession | undefined): GitlabToken { return (session ?? {})[this.connectorId] ?? {} } setSessionToken(session: GitlabSession, token: GitlabToken): void { session[this.connectorId] = token } resetSessionToken(session: GitlabSession): void { delete session[this.connectorId] } getOptions(formData: object): object { return {} // FIXME: store branch } async getLoginForm(session: GitlabSession, redirectTo: string): Promise<null> { return null } async getSettingsForm(session: GitlabSession, redirectTo: string): Promise<null> { return null } async isLoggedIn(session: GitlabSession): Promise<boolean> { return !!this.getSessionToken(session).token } /** * Get the token from return code * Set the token in the session * OAuth2 Step #2 from https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-with-proof-key-for-code-exchange-pkce */ async setToken(session: GitlabSession, loginResult: any): Promise<void> { const sessionToken = this.getSessionToken(session) if (!loginResult.state || loginResult.state !== sessionToken?.state) { this.logout(session) throw new ApiError('Invalid state', 401) } if (!sessionToken?.codeVerifier) { this.logout(session) throw new ApiError('Missing code verifier', 401) } if (!sessionToken?.codeChallenge) { this.logout(session) throw new ApiError('Missing code challenge', 401) } const response = await fetch(this.options.domain + '/oauth/token', { agent: this.getAgent(), method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: this.options.clientId, client_secret: this.options.clientSecret, code: loginResult.code, grant_type: 'authorization_code', redirect_uri: this.getRedirect(), code_verifier: sessionToken.codeVerifier, }), }) const token = await response.json() // Store the token in the session this.setSessionToken(session, { ...this.getSessionToken(session), token, }) // We need to get the user ID for listWebsites const user = await this.callApi({ session, path: 'api/v4/user' }) as any // Store the user details in the session this.setSessionToken(session, { ...this.getSessionToken(session), userId: user.id, username: user.username, }) } async logout(session: GitlabSession): Promise<void> { this.resetSessionToken(session) } async getUser(session: GitlabSession): Promise<ConnectorUser> { const user = await this.callApi({ session, path: 'api/v4/user' }) as any return { name: user.name, email: user.email, picture: user.avatar_url, storage: await toConnectorData(session, this as StorageConnector), } } async listWebsites(session: GitlabSession): Promise<WebsiteMeta[]> { const userId = this.getSessionToken(session).userId if (!userId) { this.logout(session) throw new ApiError('Missing Gitlab user ID. User not logged in?', 401) } // Handle multiple pages let page = 1 let totalPages = 1 const projects: any[] = [] do { const responseHeaders: any = {} const pageProjects = await this.callApi({ session, path: `api/v4/users/${userId}/projects`, params: { simple: true, per_page: 100, page, }, responseHeaders, }) as any[] projects.push(...pageProjects) page++ // Get the total number of pages from the response headers const total = responseHeaders['x-total-pages'] if (total) { totalPages = parseInt(total, 10) } } while (page <= totalPages) return projects .filter(p => p.name.startsWith(this.options.repoPrefix)) .map(p => ({ websiteId: p.id, name: p.name.replace(this.options.repoPrefix, ''), createdAt: p.created_at, updatedAt: p.last_activity_at, connectorUserSettings: {}, })) } /** * Read the website data * The website data file is named `website.json` and the pages are named `page-{id}.json` * The pages are stored in the `src` folder by default */ async readWebsite(session: GitlabSession, websiteId: string): Promise<WebsiteData> { const websiteDataBuf = await this.downloadRawFile(session, websiteId, WEBSITE_DATA_FILE) const websiteDataOb = JSON.parse(websiteDataBuf.toString('utf8')) as GitlabWebsiteData & WebsiteData const { fileFormatVersion, ...websiteData } = websiteDataOb // Check the file format version if (fileFormatVersion !== WEBSITE_DATA_FILE_FORMAT_VERSION) { // This should be handled by a migration mechanism console.warn('Gitlab connector: website data file format version mismatch', fileFormatVersion, '!=', WEBSITE_DATA_FILE_FORMAT_VERSION) } // This happens when the website was just created // Let grapesjs create the pages in the frontend if (!websiteData.pages) { return websiteData as WebsiteData } // Load each page in parallel const pages = await Promise.all(websiteData.pages.map(async (page: GitlabPage | Page) => { if ((page as GitlabPage).isFile) { const name = getPageSlug(page.name) const fileName = (`${name}-${page.id}`) const filePath = `${WEBSITE_PAGES_FOLDER}/${fileName}.json` const pageContent = await this.downloadRawFile(session, websiteId, filePath) const res = JSON.parse(pageContent.toString('utf8')) as Page return res } return page as Page })) // Read each page file if needed return { ...websiteData, pages, } as WebsiteData } /** * Create a new website, i.e. a new Gitlab repository with an empty website data file */ async createWebsite(session: GitlabSession, websiteMeta: WebsiteMetaFileContent): Promise<WebsiteId> { const project = await this.callApi({ session, path: 'api/v4/projects/', method: 'POST', requestBody: { name: this.options.repoPrefix + websiteMeta.name, } }) as any await this.createFile(session, project.id, WEBSITE_DATA_FILE, JSON.stringify({} as WebsiteData)) //await this.createFile(session, project.id, WEBSITE_META_DATA_FILE, JSON.stringify(websiteMeta)) //await this.updateWebsite(session, project.id, {} as WebsiteData) //await this.setWebsiteMeta(session, project.id, websiteMeta) return project.id } /** * Update the website data * Split the website data into 1 file per page + 1 file for the website data itself * Use gitlab batch API to create/update the files */ async updateWebsite(session: GitlabSession, websiteId: WebsiteId, websiteData: WebsiteData): Promise<void> { const batchActions: GitlabAction[] = [] // ** // List existing files in the pages folder const existingFiles = await this.ls({ session, websiteId, path: WEBSITE_PAGES_FOLDER, recursive: false, }) // ** // Create the pages for batch upload const pages = websiteData.pages.map((page: Page) => { const fileName = encodeURIComponent(`${getPageSlug(page.name)}-${page.id}`) const filePath = `${WEBSITE_PAGES_FOLDER}/${fileName}.json` const content = JSON.stringify(page) const newSha = computeGitBlobSha(content, false) const existingSha = existingFiles.get(filePath) if (existingSha) { if (existingSha !== newSha) { batchActions.push({ action: 'update', file_path: filePath, content, }) } // else: skip unchanged file } else { batchActions.push({ action: 'create', file_path: filePath, content, }) } return { name: page.name, id: page.id, isFile: true, } }) // ** // Delete pages that are not in the new website data for (const filePath of existingFiles.keys()) { const pageName = filePath.replace(/.*\//, '').replace(/\.json$/, '') const pageId = pageName.split('-').pop() const page = websiteData.pages.find((p: Page) => p.id === pageId) if (!page) { batchActions.push({ action: 'delete', file_path: filePath, }) } } // ** // Add the main website data file const websiteDataWithGitlabPages = { ...websiteData, fileFormatVersion: WEBSITE_DATA_FILE_FORMAT_VERSION, pages, } as GitlabWebsiteData const websiteJsonContent = JSON.stringify(websiteDataWithGitlabPages) batchActions.push({ action: 'update', file_path: WEBSITE_DATA_FILE, content: websiteJsonContent, }) // ** // Perform a batch commit return this.callApi({ session, path: `api/v4/projects/${websiteId}/repository/commits`, method: 'POST', requestBody: { branch: this.options.branch, commit_message: 'Update website data from Silex', actions: batchActions, }, }) } async deleteWebsite(session: GitlabSession, websiteId: WebsiteId): Promise<void> { // Delete repo await this.callApi({ session, path: `api/v4/projects/${websiteId}`, method: 'DELETE' }) //// Load the meta repo data //const file = await this.callApi(session, `api/v4/projects/${this.getMetaRepoPath(session)}/repository/files/${this.options.metaRepoFile}`, 'GET', null, { // ref: this.options.branch, //}) //const metaRepo = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')) as MetaRepoFileContent //const data = metaRepo.websites[websiteId] //if(!data) throw new ApiError(`Website ${websiteId} not found`, 404) //// Update or create the website meta data //delete metaRepo.websites[websiteId] //// Save the meta repo data //const project = await this.callApi(session, `api/v4/projects/${this.getMetaRepoPath(session)}/repository/files/${this.options.metaRepoFile}`, 'PUT', { // branch: this.options.branch, // commit_message: `Delete meta data of ${data.meta.name} (${websiteId}) from Silex`, // content: JSON.stringify(metaRepo), //}) } // async duplicateWebsite(session: GitlabSession, websiteId: string): Promise<void> { // // Get the repo meta data // const meta = await this.getWebsiteMeta(session, websiteId) // // List all the repository files // const files = await this.ls({ // session, // websiteId, // recursive: true, // }) // // Create a new repo // const newId = await this.createWebsite(session, { // ...meta, // name: meta.name + ' Copy ' + new Date().toISOString().replace(/T.*/, '') + ' ' + Math.random().toString(36).substring(2, 4), // }) // // Upload all files // const actions: GitlabAction[] = [] // for (const file of files.keys()) { // const content = await this.readFile(session, websiteId, file) // // From buffer to string // const contentStr = content.toString('base64') // const path = encodeURIComponent(file) // switch (file) { // case WEBSITE_DATA_FILE: // actions.push({ // action: 'update', // file_path: path, // content: contentStr, // }) // break // default: // actions.push({ // action: 'create', // file_path: path, // content: contentStr, // }) // } // } // // Perform a batch commit // return this.callApi({ // session, // path: `api/v4/projects/${newId}/repository/commits`, // method: 'POST', // requestBody: { // branch: this.options.branch, // commit_message: 'Update website data from Silex', // actions, // }, // }) // } // Fork the repo async duplicateWebsite(session: GitlabSession, websiteId: string): Promise<void> { const meta = await this.getWebsiteMeta(session, websiteId) const forkName = `${meta.name} Copy ${new Date().toISOString().slice(0, 10)} ${Math.random().toString(36).substring(2, 4)}` const safePath = sanitizeGitlabPath(forkName) const forkedProject = await this.callApi({ session, path: `api/v4/projects/${websiteId}/fork`, method: 'POST', requestBody: { name: this.options.repoPrefix + forkName, /* @ts-ignore */ path: safePath, /* @ts-ignore */ namespace: meta.namespace?.id || undefined, }, }) return forkedProject.id } async getWebsiteMeta(session: GitlabSession, websiteId: WebsiteId): Promise<WebsiteMeta> { const project = await this.callApi({ session, path: `api/v4/projects/${websiteId}` }) return { websiteId, name: project.name.replace(this.options.repoPrefix, ''), imageUrl: project.avatar_url, createdAt: project.created_at, updatedAt: project.last_activity_at, connectorUserSettings: {}, } } async setWebsiteMeta(session: GitlabSession, websiteId: WebsiteId, websiteMeta: WebsiteMetaFileContent): Promise<void> { // Rename the repo if needed const oldMeta = await this.getWebsiteMeta(session, websiteId) if (websiteMeta.name !== oldMeta.name) { await this.callApi({ session, path: `api/v4/projects/${websiteId}`, method: 'PUT', requestBody: { name: this.options.repoPrefix + websiteMeta.name, } }) } } async writeAssets(session: GitlabSession, websiteId: string, files: ConnectorFile[], status?: StatusCallback, removeUnlisted = false): Promise<void> { status && await status({ message: `Preparing ${files.length} files`, status: JobStatus.IN_PROGRESS }) // List all the files in assets folder const existingFiles = await this.ls({ session, websiteId, recursive: true, path: this.options.assetsFolder, }) // Create the actions for the batch const filesToUpload = [] as GitlabAction[] const filesToKeep = new Set(files.map(file => this.getAssetPath(file.path, false))) for (const file of files) { const filePath = this.getAssetPath(file.path, false) const content = (await contentToBuffer(file.content)).toString('base64') const existingSha = existingFiles.get(filePath) const newSha = computeGitBlobSha(content, true) if (existingSha) { if (existingSha !== newSha) { filesToUpload.push({ action: 'update', file_path: filePath, content, encoding: 'base64', }) } // else: skip unchanged file } else { filesToUpload.push({ action: 'create', file_path: filePath, content, encoding: 'base64', }) } } // Optionally remove unlisted files if (removeUnlisted) { for (const [existingFilePath] of existingFiles) { if (!filesToKeep.has(existingFilePath)) { filesToUpload.push({ action: 'delete', file_path: existingFilePath, }) } } } // Split the files into chunks to avoid the number of files limit const chunks: GitlabAction[][] = [] for (let i = 0; i < filesToUpload.length; i += MAX_BATCH_UPLOAD_SIZE) { chunks.push(filesToUpload.slice(i, i + MAX_BATCH_UPLOAD_SIZE)) } // Notify the user if nothing changed if (chunks.length === 0) { console.info('No files to upload') status && await status({ message: 'No files to upload', status: JobStatus.SUCCESS }) return } // Upload the files in chunks for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex] if (chunks.length > 1) { status && await status({ message: `Batch ${chunkIndex + 1}/${chunks.length}: Uploading ${chunk.length} files`, status: JobStatus.IN_PROGRESS }) } else { status && await status({ message: `Uploading ${files.length} file(s)`, status: JobStatus.IN_PROGRESS }) } try { await this.callApi({ session, path: `api/v4/projects/${websiteId}/repository/commits`, method: 'POST', requestBody: { branch: 'main', commit_message: `Batch update assets (${chunkIndex + 1}/${chunks.length})`, actions: chunk, } }) } catch (e) { console.error(`Batch ${chunkIndex + 1} failed`, e) status && await status({ message: `Error in batch ${chunkIndex + 1}`, status: JobStatus.ERROR }) throw e } } status && await status({ message: `All ${files.length} files uploaded`, status: JobStatus.SUCCESS }) } async readAsset(session: GitlabSession, websiteId: string, fileName: string): Promise<ConnectorFileContent> { const finalPath = this.getAssetPath(fileName, false) return this.readFile(session, websiteId, finalPath) } async deleteAssets(session: GitlabSession, websiteId: string, fileNames: string[]): Promise<void> { return this.callApi({ session, path: `api/v4/projects/${websiteId}/repository/commits`, method: 'POST', requestBody: { id: websiteId, branch: this.options.branch, commit_message: `Delete assets from Silex: ${fileNames.join(', ')}`, actions: fileNames.map(f => ({ action: 'delete', file_path: this.getAssetPath(f), })), } }) } /* * Get the meta repo path for the current user * The meta repo contains a JSON file which contains the list of websites */ //private getMetaRepoPath(session: GitlabSession): string { // if(!this.getSessionToken(session).username) throw new ApiError('Missing Gitlab user ID. User not logged in?', 401) // return encodeURIComponent(`${this.getSessionToken(session).username}/${this.options.metaRepo}`) //} ///** // * Initialize the storage with a meta repo // */ //private async initStorage(session: GitlabSession): Promise<void> { // // Create the meta repo // try { // const project = await this.callApi(session, 'api/v4/projects/', 'POST', { // name: this.options.metaRepo, // }) as any // return this.createFile(session, this.getMetaRepoPath(session), this.options.metaRepoFile, JSON.stringify({ // websites: {} // } as MetaRepoFileContent)) // } catch (e) { // console.error('Could not init storage', e.statusCode, e.httpStatusCode, e) // throw e // } //} /** * List all the files in a folder * The result is a map of file paths to their SHA */ protected async ls({ session, websiteId, recursive = false, path, }: { session: GitlabSession, websiteId: string, recursive?: boolean, path?: string, }): Promise<Map<string, string>> { const existingPaths = new Map<string, string>() let page = 1 let keepGoing = true while (keepGoing) { const responseHeaders: any = {} let tree: any[] = [] try { const params = { recursive, per_page: 100, page, } as any if (path) params.path = path tree = await this.callApi({ session, path: `api/v4/projects/${websiteId}/repository/tree`, method: 'GET', params, responseHeaders, }) } catch (e) { // Allow 404 errors // This happens when the folder does not exist // In git this just means the files don't exist yet if (e.statusCode !== 404 && e.httpStatusCode !== 404) { throw e } } // Filter the files tree .filter(item => item.type === 'blob') .forEach(item => existingPaths.set(item.path, item.id)) // Check if we need to keep going const maxPages = responseHeaders['x-total-pages'] ? parseInt(responseHeaders['x-total-pages'], 10) : 1 keepGoing = page < maxPages page++ } // Return the set of existing paths return existingPaths } }