UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

622 lines (564 loc) 20.7 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 { Client } from 'basic-ftp' import { Readable } from 'stream' import { ConnectorFile, ConnectorFileContent, HostingConnector, StatusCallback, StorageConnector, contentToReadable, contentToString, toConnectorData, toConnectorEnum} from '../../server/connectors/connectors' import { requiredParam } from '../../server/utils/validation' import { WEBSITE_DATA_FILE, WEBSITE_META_DATA_FILE } from '../../constants' import { ConnectorType, ConnectorUser, WebsiteMeta, FileMeta, JobData, JobStatus, WebsiteId, PublicationJobData, WebsiteMetaFileContent, defaultWebsiteData, WebsiteData, ConnectorOptions } from '../../types' import { ServerConfig } from '../../server/config' import { join } from 'path' import { tmpdir } from 'os' import { v4 as uuid } from 'uuid' import { JobManager } from '../../server/jobs' import { mkdtemp, rm, rmdir } from 'fs/promises' import { createReadStream } from 'fs' /** * @fileoverview FTP connector for Silex * This is a connector for Silex, which allows to store the website on a FTP server * and to publish the website on a FTP server * FIXME: As a hosting connector, this connector should only store the password in the session and the rest in the website data * to prevent publishing a site on the wrong server + multiple sites on 1 server * FIXME: Allow reuse of FTP session between calls */ export interface FtpSessionData { host: string user: string pass: string port: number secure: boolean // Storage options storageRootPath?: string // Hosting options publicationPath?: string websiteUrl?: string } export interface FtpSession { [ConnectorType.STORAGE]?: FtpSessionData [ConnectorType.HOSTING]?: FtpSessionData } interface FileStatus { file: ConnectorFile message: string status: JobStatus } export interface FtpOptions { type: ConnectorType path: string assetsFolder: string cssFolder: string // For publication only authorizeUrl: string authorizePath: string } // ** // Utils methos function formHtml(redirectTo: string, type: ConnectorType, { host, user, pass, port, secure, storageRootPath, publicationPath, websiteUrl}: FtpSessionData, err = '') { return ` ${ err && `<div class="error">${err || ''}</div>` } <form method="POST" action="${redirectTo}"> <label for="host">Host</label> <input placeholder="ftp.example.com" type="text" name="host" value="${host || ''}" /> <label for="user">User</label> <input placeholder="user" type="text" name="user" value="${user || ''}" /> <label for="pass">Pass</label> <input placeholder="****" type="password" name="pass" value="${pass || ''}" /> <label for="port">Port</label> <input placeholder="21" type="number" name="port" value="${port || '21'}" /> <div class="checkbox-container"> <input class="checkbox" type="checkbox" name="secure" value="true" ${secure ? 'checked' : ''} /> <label for="secure">Secure</label> </div> ${ type === ConnectorType.STORAGE ? ` <details> <summary>Storage options</summary> <p>If you are not sure, don't change this</p> <label for="storageRootPath">Root path where to store the website files</label> <input placeholder="/silex/" type="text" name="storageRootPath" value="${storageRootPath || '/silex/'}" /> </details> ` : ''} ${ type === ConnectorType.HOSTING ? ` <fieldset> <legend>Publication options</legend> <label for="publicationPath">Path where to publish</label> <input placeholder="/public_html/" type="text" name="publicationPath" value="${publicationPath || ''}" /> <label for="websiteUrl">URL where to the site will be accessible</label> <input placeholder="https://mysite.com/" type="text" name="websiteUrl" value="${websiteUrl || ''}" /> </fieldset> ` : ''} <div class="button-container"> <button type="submit" class="primary-button">Login</button> <button type="button" class="secondary-button">Cancel</button> </div> </form> ` } const formCss = ` /* Reset default form styles */ form { margin: 0; padding: 0; } /* Center the form */ body { display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f5f5f5; } /* Style form container */ form { width: 400px; padding: 20px; border-radius: 4px; background-color: white; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } /* Style form labels */ label { font-weight: bold; margin-bottom: 5px; color: #333; } /* Style form inputs */ input { width: 100%; padding: 8px; margin-bottom: 10px; border: 1px solid #ccc; border-radius: 4px; } input.checkbox { width: 20px; height: 20px; } fieldset { padding: 20px; } /* Style form buttons */ .button-container { display: flex; justify-content: space-between; margin-top: 20px; } .primary-button, .secondary-button { width: 48%; padding: 10px; border: none; border-radius: 4px; cursor: pointer; transition: background-color 0.3s ease; } .primary-button { background-color: #4caf50; color: white; } .secondary-button { background-color: #e0e0e0; color: #333; } .primary-button:hover, .secondary-button:hover { background-color: #388e3c; } /* Style form checkbox */ .checkbox-container { display: flex; align-items: center; margin-bottom: 10px; margin: 20px 0; } .checkbox-container label { margin-left: 5px; color: #333; } /* Style details and summary elements */ details { margin-top: 20px; color: #333; } summary { font-weight: bold; cursor: pointer; } details > p { margin-top: 10px; } ` /** * @class FtpConnector * @implements {HostingConnector} * @implements {StorageConnector} */ export default class FtpConnector implements StorageConnector<FtpSession> { connectorId = 'ftp' displayName = 'Ftp' icon = '/assets/ftp.png' options: FtpOptions connectorType: ConnectorType color = '#ffffff' background = '#0066CC' constructor(config: ServerConfig, opts: Partial<FtpOptions>) { this.options = { path: '', assetsFolder: 'assets', cssFolder: 'css', authorizeUrl: './api/authorize/ftp/', authorizePath: './api/authorize/ftp/', ...opts, } as FtpOptions if(!this.options.type) throw new Error('missing type in option of FtpConnector') this.connectorType = toConnectorEnum(this.options.type) } // ** // Utils sessionData(session: FtpSession): FtpSessionData { return session[`ftp-${this.options.type}`] ?? {} as FtpSessionData } rootPath(session: FtpSession): string { return this.connectorType === ConnectorType.STORAGE ? requiredParam<string>(this.sessionData(session).storageRootPath, 'storage root path') : requiredParam<string>(this.sessionData(session).publicationPath, 'publication path') } // ** // FTP methods private async write(ftp: Client, path: string, content: ConnectorFileContent, progress?: (message: string) => void): Promise<void> { ftp.trackProgress(info => { progress && progress(`Uploading ${info.bytes / 1000} KB}`) }) progress && progress('Upload started') await ftp.uploadFrom(contentToReadable(content), path) progress && progress('Upload complete') } private async read(ftp: Client, path: string): Promise<Readable> { const tempDir = await mkdtemp(join(tmpdir(), 'silex-tmp')) const tempPath = join(tempDir, 'silex-ftp.tmp') await ftp.downloadTo(tempPath, path) const rStream = createReadStream(tempPath) await rm(tempPath) await rmdir(tempDir) return rStream } private async readdir(ftp: Client, path: string): Promise<FileMeta[]> { const list = await ftp.list(path) return list.map((file) => ({ name: file.name, isDir: file.isDirectory, size: file.size, createdAt: file.modifiedAt, updatedAt: file.modifiedAt, metaData: file, } as FileMeta)) } private async mkdir(ftp: Client, path: string) { return ftp.ensureDir(path) } private async rmdir(ftp: Client, path: string) { return ftp.removeDir(path) } private async unlink(ftp: Client, path: string) { return ftp.remove(path) } private async getClient({host, user, pass, port, secure}): Promise<Client> { const ftp = new Client() await ftp.access({ host, port, user, password: pass, secure, }) return ftp } private closeClient(ftp: Client) { if(ftp && !ftp.closed) { ftp.close() } else { console.warn('ftp already closed') } } // ** // Connector interface getOptions(formData: object): ConnectorOptions { return { // Storage storageRootPath: formData['storageRootPath'], // Hosting publicationPath: formData['publicationPath'], websiteUrl: formData['websiteUrl'], } } async getOAuthUrl(session: FtpSession): Promise<null> { return null } async getLoginForm(session: FtpSession, redirectTo: string): Promise<string | null> { const { host, user, pass, port, secure, publicationPath, storageRootPath, websiteUrl } = this.sessionData(session) return ` <style> ${formCss} </style> ${formHtml(redirectTo, this.connectorType, { host, user, pass, port, secure, publicationPath, storageRootPath, websiteUrl })} ` } async getSettingsForm(session: FtpSession, redirectTo: string): Promise<string | null> { return null } async setToken(session: FtpSession, token: object): Promise<void> { // Check all required params are present const { host, user, pass, port, secure = false, publicationPath, storageRootPath, websiteUrl } = token as FtpSessionData requiredParam(session, 'session') requiredParam(host, 'host') requiredParam(user, 'user') requiredParam(pass, 'pass') requiredParam(port, 'port') // Check if the connection is valid const ftp = await this.getClient({ host, user, pass, port, secure }) // Save the token session[`ftp-${this.connectorType}`] = { host, user, pass, port, secure, publicationPath, storageRootPath, websiteUrl } // Clean up this.closeClient(ftp) } async logout(session: FtpSession) { delete session[`ftp-${this.options.type}`] } async getUser(session: FtpSession): Promise<ConnectorUser> { return { name: this.sessionData(session).user, picture: 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' height=\'1em\' viewBox=\'0 0 448 512\'%3E%3C!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --%3E%3Cpath d=\'M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304H178.3z\'/%3E%3C/svg%3E', storage: await toConnectorData(session, this) } } async isLoggedIn(session: FtpSession) { try { if (!session[`ftp-${this.options.type}`]) { return false } const ftp = await this.getClient(this.sessionData(session)) this.closeClient(ftp) return true } catch(err) { return false } } async setWebsiteMeta(session: FtpSession, id: string, data: WebsiteMetaFileContent): Promise<void> { const websiteId = requiredParam<WebsiteId>(id, 'website id') const path = join(this.rootPath(session), websiteId, WEBSITE_META_DATA_FILE) const ftp = await this.getClient(this.sessionData(session)) await this.write(ftp, path, JSON.stringify(data)) this.closeClient(ftp) } async getWebsiteMeta(session: FtpSession, id: WebsiteId): Promise<WebsiteMeta> { try { const websiteId = requiredParam<WebsiteId>(id, 'website id') const ftp = await this.getClient(this.sessionData(session)) // Get stats for the website folder const folder = join(this.rootPath(session), websiteId) const path = join(this.rootPath(session), websiteId, WEBSITE_META_DATA_FILE) const readable = await this.read(ftp, path) const meta = JSON.parse(await contentToString(readable)) as WebsiteMetaFileContent this.closeClient(ftp) // Return all meta return { websiteId, ...meta, } } catch(err) { console.error(err) throw err } } // ** // Storage interface /** * Create necessary folders * Assets and root folders */ async createWebsite(session: FtpSession): Promise<WebsiteId> { const ftp = await this.getClient(this.sessionData(session)) // Generate a random id const id = uuid() // // create root folder // const rootPath = join(this.storageRootPath(session), id) // await this.mkdir(ftp, rootPath) // create assets folder const assetsPath = join(this.rootPath(session), id, '/assets') await this.mkdir(ftp, assetsPath) // create website data file const websiteDataPath = join(this.rootPath(session), id, WEBSITE_DATA_FILE) await this.write(ftp, websiteDataPath, JSON.stringify(defaultWebsiteData)) // create website meta data file const websiteMetaDataPath = join(this.rootPath(session), id, WEBSITE_META_DATA_FILE) await this.write(ftp, websiteMetaDataPath, JSON.stringify({})) // Clean up this.closeClient(ftp) // All good return id } async listWebsites(session: FtpSession): Promise<WebsiteMeta[]> { const storageRootPath = this.rootPath(session) const ftp = await this.getClient(this.sessionData(session)) const files = await this.readdir(ftp, storageRootPath) const list = await Promise.all(files.map(async file => { const websiteId = file.name const websiteMeta = await this.getWebsiteMeta(session, websiteId) return websiteMeta })) this.closeClient(ftp) return list } async readWebsite(session: FtpSession, websiteId: string): Promise<WebsiteData | Readable> { const storageRootPath = this.rootPath(session) const ftp = await this.getClient(this.sessionData(session)) const websiteDataPath = join(storageRootPath, websiteId, WEBSITE_DATA_FILE) const data = await this.read(ftp, websiteDataPath) this.closeClient(ftp) return data } async updateWebsite(session: FtpSession, websiteId: string, data: WebsiteData): Promise<void> { const storageRootPath = this.rootPath(session) const ftp = await this.getClient(this.sessionData(session)) const websiteDataPath = join(storageRootPath, websiteId, WEBSITE_DATA_FILE) await this.write(ftp, websiteDataPath, JSON.stringify(data)) this.closeClient(ftp) } async deleteWebsite(session: FtpSession, websiteId: string): Promise<void> { const storageRootPath = this.rootPath(session) const ftp = await this.getClient(this.sessionData(session)) const websitePath = join(storageRootPath, websiteId) await this.rmdir(ftp, websitePath) this.closeClient(ftp) } async duplicateWebsite(session: FtpSession, websiteId: string): Promise<void> { const newWebsiteId = uuid() const storageRootPath = this.rootPath(session) const ftp = await this.getClient(this.sessionData(session)) const websitePath = join(storageRootPath, websiteId) const newWebsitePath = join(storageRootPath, newWebsiteId) const tempDir = await mkdtemp(tmpdir()) await ftp.downloadToDir(websitePath, tempDir) await ftp.uploadFromDir(tempDir, newWebsitePath) this.closeClient(ftp) } async writeAssets( session: any, id: WebsiteId, files: ConnectorFile[], statusCbk?: StatusCallback, ): Promise<void> { return this.writeFile(session, id, files, this.options.assetsFolder, statusCbk) } async writeFile( session: any, id: WebsiteId, files: ConnectorFile[], relativePath: string, statusCbk?: StatusCallback, ): Promise<void> { // Connect to FTP server statusCbk && statusCbk({ message: 'Connecting to FTP server', status: JobStatus.IN_PROGRESS, }) const ftp = await this.getClient(this.sessionData(session)) const rootPath = this.rootPath(session) // Make sure that root folder exists statusCbk && statusCbk({ message: 'Making sure that root folder exists', status: JobStatus.IN_PROGRESS, }) // Useless as ftp write will create the folder // await this.mkdir(ftp, rootPath) // Write files let lastFile: ConnectorFile | undefined try { // Sequentially write files for(const file of files) { statusCbk && statusCbk({ message: `Writing file ${file.path}`, status: JobStatus.IN_PROGRESS, }) const dstPath = join(this.options.path, rootPath, id, relativePath, file.path) lastFile = file const result = await this.write(ftp, dstPath, file.content, message => { statusCbk && statusCbk({ message: `Writing file ${file.path.split('/').pop()} to ${dstPath.split('/').slice(0, -1).join('/')}: ${message}`, status: JobStatus.IN_PROGRESS, }) }) } this.closeClient(ftp) statusCbk && statusCbk({ message: `Finished writing ${files.length} files to ${rootPath}`, status: JobStatus.SUCCESS, }) } catch(err) { // Not sure why it never gets here statusCbk && statusCbk({ message: `Error writing file ${lastFile?.path}: ${err.message}`, status: JobStatus.ERROR, }) this.closeClient(ftp) } } async readAsset(session: FtpSession, id: string, path: string): Promise<ConnectorFileContent> { if (!this.sessionData(session)) throw new Error('Not logged in') const storageRootPath = this.rootPath(session) const ftp = await this.getClient(this.sessionData(session)) const dirPath = join(this.options.path, storageRootPath, id, this.options.assetsFolder, path) const asset = await this.read(ftp, dirPath) this.closeClient(ftp) return asset } async deleteAssets(session: FtpSession, id: WebsiteId, paths: string[]): Promise<void> { const storageRootPath = this.rootPath(session) const ftp = await this.getClient(this.sessionData(session)) await Promise.all( paths.map((path) => this.unlink(ftp, join(this.options.path, storageRootPath, id, this.options.assetsFolder, path))) ) this.closeClient(ftp) } // ** // Hosting interface async getUrl(session: FtpSession, id: WebsiteId): Promise<string> { // FIXME: do not store websiteUrl in the session, but in the website data console.warn('FIXME: do not store websiteUrl in the session, but in the website data') return this.sessionData(session).websiteUrl ?? '' } async publish(session: FtpSession, id: WebsiteId, files: ConnectorFile[], {startJob, jobSuccess, jobError}: JobManager): Promise<JobData> { const job = startJob(`Publishing to ${this.displayName}`) as PublicationJobData job.logs = [[`Publishing to ${this.displayName}`]] job.errors = [[]] // Create folders const rootPath = this.rootPath(session) const ftp = await this.getClient(this.sessionData(session)) await this.mkdir(ftp, rootPath) await this.mkdir(ftp, join(rootPath, this.options.assetsFolder)) await this.mkdir(ftp, join(rootPath, this.options.cssFolder)) // Write files // Do not await for the result, return the job and continue the publication in the background this.writeFile(session, '', files, '', async ({status, message}) => { // Update the job status job.status = status job.message = message job.logs[0].push(message) if(status === JobStatus.SUCCESS) { jobSuccess(job.jobId, message) } else if(status === JobStatus.ERROR) { job.errors[0].push(message) jobError(job.jobId, message) } }) return job } }