UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

1,293 lines (1,178 loc) 43.2 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, LEGACY_WEBSITE_PAGES_FOLDER, 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, EMPTY_WEBSITE } 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' import { Page } from 'grapesjs' import { stringify, split, merge, getPagesFolder } from '../../server/utils/websiteDataSerialization' /** * 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 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('[GitlabConnector] Invalid GET request: GET requests should not have a body', { url, method, body: requestBody, params }) } // With or without body let response: Response const body = requestBody ? JSON.stringify(requestBody) : undefined try { if (body && Buffer.byteLength(body) > MAX_BODY_SIZE_KB * 1024) { console.warn('[GitlabConnector] Request body size exceeds Gitlab API limit', { size: Buffer.byteLength(body), maxAllowed: MAX_BODY_SIZE_KB * 1024, url, method, params, session, }) } response = await fetch(url, requestBody && method !== 'GET' ? { agent: this.getAgent(), method, headers, body, } : { agent: this.getAgent(), method, headers, }) } catch (e) { console.error('[GitlabConnector] Failed to reach Gitlab API endpoint', { error: e, url, method, body: requestBody, params, session, stack: e?.stack || new Error().stack, }) throw new ApiError(`Could not reach Gitlab API: ${e.message || e}`, 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('[GitlabConnector] Failed to read response body from Gitlab API', { status: response.status, statusText: response.statusText, url, method, body: requestBody, params, error: e }) throw new ApiError(`Gitlab API: Could not read response body (${e.message})`, 500) } }() if (!response.ok) { console.error('[GitlabConnector] Gitlab API responded with error status', { status: response.status, statusText: response.statusText, url, method, body: requestBody, params, responseText: text }) if (text.includes('A file with this name doesn\'t exist')) { throw new ApiError('Gitlab API: File 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('[GitlabConnector] Failed to refresh Gitlab OAuth token', { status: response.status, statusText: response.statusText, message, refresh_token: token?.refresh_token }) this.logout(session) throw new ApiError(`Gitlab API: Could not refresh token (${message})`, response.status) } } else { const message = response.statusText console.error('[GitlabConnector] Unhandled Gitlab API error response', { status: response.status, statusText: response.statusText, url, method, body: requestBody, params, responseText: text, message }) throw new ApiError(`Gitlab API error: ${message} (${text})`, response.status) } } let json: { message: string, error: string } | any try { json = JSON.parse(text) } catch (e) { if (!response.ok) { throw e } else { console.error('[GitlabConnector] Response from Gitlab API is not valid JSON', { statusText: response.statusText, url, method, body: requestBody, params, responseText: text, error: e, session, stack: e?.stack || new Error().stack, }) 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) { console.error('[GitlabConnector] GitLab raw file not found', { status: fileRes.status, statusText: fileRes.statusText, url: rawUrl, responseText: errText }) throw new ApiError(`GitLab raw file not found: filePath="${filePath}" (status ${fileRes.status})`, 404) } console.error('[GitlabConnector] Failed to fetch raw file from GitLab', { filePath, url: rawUrl, status: fileRes.status, statusText: fileRes.statusText, responseText: errText }) throw new ApiError(`Failed to fetch raw file from GitLab: filePath="${filePath}" - ${fileRes.statusText} (${errText})`, fileRes.status) } try { const buffer = await fileRes.buffer() return buffer } catch (e) { console.error('[GitlabConnector] Error reading binary content from GitLab raw file', e) throw new ApiError('Failed to read binary content from GitLab raw file', 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) // Handle state that may be JSON with redirect info or a plain string // Redirect info is when the user is comming from the /fork/ page of the dashboard let receivedState = loginResult.state try { const parsed = JSON.parse(loginResult.state) if (parsed.state) { receivedState = parsed.state } } catch { // Plain text } if (!receivedState || receivedState !== 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: { 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) && !p.marked_for_deletion_on && !p.marked_for_deletion_at ) .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 websiteDataContent = websiteDataBuf.toString('utf8') // Use the common merge function to reconstruct website data const pageLoader = async (pagePath: string): Promise<string> => { const pageBuffer = await this.downloadRawFile(session, websiteId, pagePath) return pageBuffer.toString('utf8') } return await merge(websiteDataContent, pageLoader) } /** * 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, stringify(EMPTY_WEBSITE)) //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[] = [] // ** // Handle the legacy sites that have no pagesFolder // We want them to use "pages/" by default despite their files being in "src/" for now let isLegacySite = !websiteData.pagesFolder // ** // Backward compatibility case // The second time a legacy site is saved, the new `pages/` folder has been created // but the front end still doesn't have the `pagesFolder` key // This will be the case until the front end reloads if (isLegacySite) { const rootFiles = await this.ls({ session, websiteId, path: WEBSITE_PAGES_FOLDER, recursive: false, }) if (rootFiles.size) { // Here the new `pages/` folder has been created already isLegacySite = false websiteData.pagesFolder = WEBSITE_PAGES_FOLDER } } // ** // List existing files in the OLD pages folder (to detect files to delete) const existingFiles = await this.ls({ session, websiteId, path: getPagesFolder(websiteData), recursive: false, }) // ** // Force pagesFolder to 'pages' for writing if not defined if (isLegacySite) { websiteData.pagesFolder = WEBSITE_PAGES_FOLDER } // ** // Use the common split function to create files const filesToWrite = split(websiteData) // ** // Process each file to create/update for (const file of filesToWrite) { const filePath = file.path const content = file.content const newSha = computeGitBlobSha(content, false) const existingSha = existingFiles.get(filePath) || (filePath === WEBSITE_DATA_FILE ? 'always-update' : undefined) 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, }) } } // ** // Delete pages that are not in the new website data const pathsToWrite = filesToWrite.map(f => f.path) for (const filePath of existingFiles.keys()) { if (!pathsToWrite.includes(filePath)) { batchActions.push({ action: 'delete', file_path: filePath, }) } } // ** // 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', }) } // Fork the repo (user's own project) 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 } /** * Fork an external/public GitLab project (from any user/organization) * @param session - The user session * @param gitlabUrl - The project path in the "username/repo" format * @returns The new website ID (project ID) */ async forkWebsite(session: GitlabSession, gitlabUrl: string): Promise<string> { // Only accept "username/repo" pattern (no URLs) const projectPath = gitlabUrl.trim() if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(projectPath)) { throw new ApiError('Invalid project path. Use the "username/repo" format.', 400) } // URL-encode the project path for the API const encodedPath = encodeURIComponent(projectPath) // First, get the source project info to extract its name let sourceProject: any try { sourceProject = await this.callApi({ session, path: `api/v4/projects/${encodedPath}`, method: 'GET', }) } catch (e) { if (e.httpStatusCode === 404) { throw new ApiError(`Project not found: ${projectPath}. Make sure the project exists and is public or you have access to it.`, 404) } throw e } // Generate a unique name for the fork const sourceName = sourceProject.name.replace(this.options.repoPrefix, '') const forkName = `${sourceName} ${new Date().toISOString().slice(0, 10)} ${Math.random().toString(36).substring(2, 4)}` const safePath = sanitizeGitlabPath(this.options.repoPrefix + forkName) // Fork the project to the user's namespace const forkedProject = await this.callApi({ session, path: `api/v4/projects/${encodedPath}/fork`, method: 'POST', requestBody: { name: this.options.repoPrefix + forkName, /* @ts-ignore */ path: safePath, visibility: 'private', }, }) // Wait for the fork to complete (GitLab forks asynchronously) const forkedProjectId = forkedProject.id.toString() const maxAttempts = 30 // 30 attempts * 2 seconds = 60 seconds max const pollInterval = 2000 // 2 seconds for (let attempt = 0; attempt < maxAttempts; attempt++) { const project = await this.callApi({ session, path: `api/v4/projects/${forkedProjectId}`, method: 'GET', }) if (project.import_status === 'finished' || project.import_status === 'none') { return forkedProjectId } if (project.import_status === 'failed') { throw new ApiError(`Fork failed: ${project.import_error || 'Unknown error'}`, 500) } // Status is 'scheduled' or 'started', wait and retry await new Promise(resolve => setTimeout(resolve, pollInterval)) } return forkedProjectId } async getWebsiteMeta(session: GitlabSession, websiteId: WebsiteId): Promise<WebsiteMeta> { const project = await this.callApi({ session, path: `api/v4/projects/${websiteId}` }) // Defaults let pagesUrl: string | undefined = undefined let pagesVisibility: string | undefined = undefined let lastJob: { date: string, status: string, id?: number, name?: string, webUrl?: string } | undefined = undefined // Try to get GitLab Pages info try { const pages = await this.callApi({ session, path: `api/v4/projects/${websiteId}/pages` }) pagesUrl = pages.url // pages.access_control_level may be present; fallback to project.visibility if not pagesVisibility = pages.access_control_level || project.visibility } catch (e) { // If pages are not enabled or call fails, ignore and leave undefined } // Try to get last relevant job (prefer 'pages' job, otherwise most recent job) try { const jobs = await this.callApi({ session, path: `api/v4/projects/${websiteId}/jobs`, params: { per_page: 1, order_by: 'finished_at', sort: 'desc' } }) if (Array.isArray(jobs) && jobs.length > 0) { lastJob = { date: jobs[0].finished_at || jobs[0].created_at || jobs[0].started_at, status: jobs[0].status, id: jobs[0].id, name: jobs[0].name, webUrl: jobs[0].web_url } } } catch (e) { // With no jobs, leave lastJob undefined } // Fork info - fetch the forked-from project to get license let forkedFrom: { id: string, name: string, webUrl?: string, license?: string } | undefined = undefined if (project.forked_from_project) { let license: string | undefined = undefined try { const templateProject = await this.callApi({ session, path: `api/v4/projects/${project.forked_from_project.id}`, params: { license: true } }) license = templateProject.license?.name || templateProject.license?.nickname || templateProject.license?.key } catch (e) { // Template may be private or inaccessible } forkedFrom = { id: String(project.forked_from_project.id), name: project.forked_from_project.name, webUrl: project.forked_from_project.web_url, license } } const result = { websiteId, name: project.name.replace(this.options.repoPrefix, ''), imageUrl: project.avatar_url, createdAt: project.created_at, updatedAt: project.last_activity_at, connectorUserSettings: {}, visibility: project.visibility, repoUrl: project.web_url, forkCount: project.forks_count, starCount: project.star_count, forkedFrom, pagesUrl, pagesVisibility, lastJob } console.log('META', {result, websiteId}) return result } 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 } }