UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

463 lines (419 loc) 18 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 { Router } from 'express' import formidable from 'formidable' import PersistentFile from 'formidable/src/PersistentFile' import { API_WEBSITE_ASSET_READ, API_WEBSITE_ASSETS_WRITE, API_WEBSITE_READ, API_WEBSITE_WRITE, API_WEBSITE_DELETE, API_WEBSITE_META_READ, API_WEBSITE_META_WRITE, API_WEBSITE_LIST, API_WEBSITE_CREATE, API_PATH, API_WEBSITE_PATH, API_WEBSITE_DUPLICATE } from '../../constants' import { createReadStream } from 'fs' import { ApiError, ApiWebsiteAssetsReadParams, ApiWebsiteAssetsReadQuery, ApiWebsiteAssetsReadResponse, ApiWebsiteAssetsWriteQuery, ApiWebsiteAssetsWriteResponse, ApiWebsiteDeleteQuery, ApiWebsiteReadQuery, ApiWebsiteReadResponse, ApiWebsiteWriteBody, ApiWebsiteWriteQuery, ConnectorId, ConnectorType, WebsiteMeta, WebsiteData, WebsiteId, ApiWebsiteListQuery, ApiWebsiteListResponse, ApiWebsiteMetaReadQuery, ApiWebsiteMetaReadResponse, ApiWebsiteMetaWriteQuery, ApiWebsiteMetaWriteBody, WebsiteMetaFileContent, ApiWebsiteMetaWriteResponse, ApiWebsiteWriteResponse, ApiWebsiteCreateQuery, ApiWebsiteCreateBody, ApiWebsiteDuplicateQuery } from '../../types' import { ConnectorFile, ConnectorFileContent, ConnectorSession, StorageConnector, getConnector } from '../connectors/connectors' import { Readable } from 'stream' import { requiredParam } from '../utils/validation' import { basename, join } from 'path' import { ServerConfig } from '../config' import { ServerEvent, WebsiteStoreEndEventType, WebsiteStoreStartEventType, WebsiteAssetStoreStartEventType, WebsiteAssetStoreEndEventType } from '../events' /** * @fileoverview Website plugin for Silex * This plugin provides the website API to Silex server */ export default function (config: ServerConfig, opts = {}): Router { // Options with defaults const options = { // Default constants assetsPath: '/assets', // Options ...opts } // Create a new router const router = Router() // Load website data router.get(API_WEBSITE_READ, async (req, res, next) => { const query = req.query as ApiWebsiteReadQuery const { websiteId, connectorId } = query const session = req['session'] as ConnectorSession if(!websiteId) { // List websites next() return } try { // Get website data const websiteData: WebsiteData | Readable = await readWebsite( session, websiteId, connectorId, ) if (websiteData instanceof Readable) { websiteData.pipe(res.type('application/json')) } else { res.json(websiteData as ApiWebsiteReadResponse) } } catch (e) { if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } }) // List websites router.get(API_WEBSITE_LIST, async (req, res) => { const query: ApiWebsiteListQuery = req.query const { connectorId } = query const session = req['session'] as ConnectorSession try { // List websites const websites = await listWebsites(req['session'], query.connectorId as string | undefined) res.json(websites as ApiWebsiteListResponse) } catch (e) { console.error('Error getting website data', e) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } }) // Save website data router.post(API_WEBSITE_WRITE, async (req, res) => { try { // Check input const query: ApiWebsiteWriteQuery = req.query as any const body: ApiWebsiteWriteBody = req.body const websiteId= requiredParam<WebsiteId>(query.websiteId, 'Website id') const websiteData = requiredParam<WebsiteData>(body, 'Website data') as WebsiteData const connectorId = query.connectorId // Optional // Hook to modify the website data before saving config.emit(ServerEvent.WEBSITE_STORE_START, { websiteId, websiteData, connectorId } as WebsiteStoreStartEventType) // Save website data await writeWebsite( req['session'], websiteId, websiteData, connectorId, ) config.emit(ServerEvent.WEBSITE_STORE_END, null as WebsiteStoreEndEventType) res.status(200).json({ message: 'Website saved' } as ApiWebsiteWriteResponse) } catch (e) { console.error('Error saving website data', e) config.emit(ServerEvent.WEBSITE_STORE_END, e as WebsiteStoreEndEventType) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } }) // Create website or update website meta router.put(API_WEBSITE_CREATE, async (req, res) => { try { // Check input const session = req['session'] as ConnectorSession const query: ApiWebsiteCreateQuery = req.query as any const body: ApiWebsiteCreateBody = req.body const websiteMeta = requiredParam<WebsiteMetaFileContent>(body, 'Website meta') as WebsiteMeta const connectorId = query.connectorId const connector = await getConnector<StorageConnector>(config, session, ConnectorType.STORAGE, connectorId) if(!connector) { throw new ApiError(`Connector ${connectorId} not found`, 500) } // Create website await connector.createWebsite( session, websiteMeta, ) res.json({ message: 'Website meta saved' } as ApiWebsiteMetaWriteResponse) } catch (e) { console.error('Error saving website meta', e) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } }) // Update website meta router.post(API_WEBSITE_META_WRITE, async (req, res) => { try { // Check input const session = req['session'] as ConnectorSession const query: ApiWebsiteMetaWriteQuery = req.query as any const body: ApiWebsiteMetaWriteBody = req.body const websiteId= requiredParam<WebsiteId>(query.websiteId, 'Website id') const websiteMeta = requiredParam<WebsiteMetaFileContent>(body, 'Website meta') as WebsiteMeta const connectorId = query.connectorId const connector = await getConnector<StorageConnector>(config, session, ConnectorType.STORAGE, connectorId) if(!connector) { throw new ApiError(`Connector ${connectorId} not found`, 500) } // Update website meta await connector.setWebsiteMeta( session, websiteId, websiteMeta, ) res.json({ message: 'Website meta saved' } as ApiWebsiteMetaWriteResponse) } catch (e) { console.error('Error saving website meta', e) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } }) // Get website meta router.get(API_WEBSITE_META_READ, async (req, res) => { try { const session = req['session'] as ConnectorSession const query: ApiWebsiteMetaReadQuery = req.query as any const websiteId= requiredParam<WebsiteId>(query.websiteId, 'Website id') const connectorId = query.connectorId const connector = await getConnector<StorageConnector>(config, session, ConnectorType.STORAGE, connectorId) if(!connector) { throw new ApiError(`Connector ${connectorId} not found`, 500) } const websiteMeta: WebsiteMeta = await connector.getWebsiteMeta(session, websiteId) res.json(websiteMeta as ApiWebsiteMetaReadResponse) } catch (e) { console.error('Error getting website meta', e) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } }) // Delete website router.delete(API_WEBSITE_DELETE, async (req, res) => { try { const query: ApiWebsiteDeleteQuery = req.query as any const websiteId= requiredParam<WebsiteId>(query.websiteId, 'Website id') await deleteWebsite(req['session'], websiteId, query.connectorId) res.status(200).json({ message: 'Website deleted' } as ApiError) } catch (e) { console.error('Error deleting website data', e) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } }) // Duplicate website router.post(API_WEBSITE_DUPLICATE, async (req, res) => { try { const query: ApiWebsiteDuplicateQuery = req.query as any const websiteId= requiredParam<WebsiteId>(query.websiteId, 'New website id') await duplicateWebsite(req['session'], websiteId, query.connectorId) res.status(200).json({ message: 'Website duplicated' } as ApiError) } catch (e) { console.error('Error duplicating website data', e) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } }) // Load assets router.get(API_WEBSITE_ASSET_READ + '/:path', async (req, res) => { { try { const query: ApiWebsiteAssetsReadQuery = req.query as any const params: ApiWebsiteAssetsReadParams = req.params as any const websiteId= requiredParam<WebsiteId>(query.websiteId, 'Website id') const path = requiredParam<string>(params.path, 'path') const asset: ConnectorFileContent = await readAsset(req['session'], websiteId, path, query.connectorId) // Set content type res.contentType(basename(path)) // Send the file if (asset instanceof Readable) { // Stream asset.pipe(res) } else { // Buffer or string res.send(asset as ApiWebsiteAssetsReadResponse) } } catch (e) { console.error('Error getting asset', e) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } } } }) // Upload assets router.post(API_WEBSITE_ASSETS_WRITE, async (req, res) => { try { // Check input const query: ApiWebsiteAssetsWriteQuery = req.query as any const websiteId = requiredParam<WebsiteId>(query.websiteId as WebsiteId, 'Website id') // Get the file data from the request const form = formidable({ multiples: true, keepExtensions: true, }) const connectorId = query.connectorId // Optional // Retrive the files const files: ConnectorFile[] = await new Promise<ConnectorFile[]>((resolve, reject) => { form.parse(req, async (err, fields, _files) => { if (err) { console.error('Error parsing upload data', err) reject(new ApiError('Error parsing upload data: ' + err.message, 400)) } else { const files = ([].concat(_files['files[]'] as PersistentFile) as PersistentFile[]) .map(file => file.toJSON()) .map(file => ({ path: `/${file.originalFilename}`, content: createReadStream(file.filepath), })) resolve(files) } }) }) // Hook to modify the files config.emit(ServerEvent.WEBSITE_ASSET_STORE_START, { files, websiteId, connectorId } as WebsiteAssetStoreStartEventType) // Write the files const result = await writeAssets(req['session'], websiteId, files, connectorId) // Base URL of silex serve const baseUrl = new URL(config.url).pathname.replace(/\/$/, '') // Return the file URLs to insert in the website // As expected by grapesjs (https://grapesjs.com/docs/modules/Assets.html#uploading-assets) const data = result.map(path => join( // We should return path without this line, as it is saved, not as it is displayed // But this url is sent straight to grapesjs, so we need to return the url as it is displayed baseUrl, API_PATH, API_WEBSITE_PATH, API_WEBSITE_ASSET_READ, path, ) + `?websiteId=${websiteId}&connectorId=${connectorId ? connectorId : ''}` // As expected by wesite API (readAsset) ) // Return the file URLs res.json({ data, } as ApiWebsiteAssetsWriteResponse) // Hook for plugins config.emit(ServerEvent.WEBSITE_ASSET_STORE_END, null as WebsiteAssetStoreEndEventType) } catch (e) { console.error('Error uploading assets', e) if (e.httpStatusCode) { res.status(e.httpStatusCode).json({ message: e.message } as ApiError) } else { res.status(500).json({ message: e.message } as ApiError) } // Hook for plugins config.emit(ServerEvent.WEBSITE_ASSET_STORE_END, e as WebsiteAssetStoreEndEventType) } }) /** * Get the desired connector * Can be the default connector or a specific one */ async function getStorageConnector(session: any, connectorId?: string): Promise<StorageConnector> { const storageConnector = await getConnector(config, session, ConnectorType.STORAGE, connectorId) // ?? config.getStorageConnectors()[0] if (!storageConnector) { throw new ApiError('No storage connector found', 404) } if (!await storageConnector.isLoggedIn(session)) { throw new ApiError('Not logged in', 401) } return storageConnector as StorageConnector } /** * Read the website data */ async function readWebsite(session: any, websiteId: string, connectorId?: string): Promise<WebsiteData | Readable> { // Get the desired connector const storageConnector = await getStorageConnector(session, connectorId) // Return website data return storageConnector.readWebsite(session, websiteId) } /** * List existing websites */ async function listWebsites(session: any, connectorId?: string): Promise<WebsiteMeta[]> { // Get the desired connector const storageConnector = await getStorageConnector(session, connectorId) // List websites return storageConnector.listWebsites(session) } /** * Write the website data to the connector */ async function writeWebsite(session: any, websiteId: WebsiteId, websiteData: WebsiteData, connectorId?: ConnectorId): Promise<void> { // Get the desired connector const storageConnector = await getStorageConnector(session, connectorId) // Write the website data await storageConnector.updateWebsite(session, websiteId, websiteData) } /** * Delete a website */ async function deleteWebsite(session: any, websiteId: string, connectorId?: string): Promise<void> { // Get the desired connector const storageConnector = await getStorageConnector(session, connectorId) // Delete the website return storageConnector.deleteWebsite(session, websiteId) } /** * Duplicate a website */ async function duplicateWebsite(session: any, websiteId: string, connectorId?: string): Promise<void> { // Get the desired connector const storageConnector = await getStorageConnector(session, connectorId) // Duplicate the website return storageConnector.duplicateWebsite(session, websiteId) } /** * Read an asset */ async function readAsset(session: any, websiteId: string, fileName: string, connectorId?: string): Promise<ConnectorFileContent> { // Get the desired connector const storageConnector = await getStorageConnector(session, connectorId) // Read the asset from the connector return storageConnector.readAsset(session, websiteId, `/${fileName}`) } /** * Write an asset to the connector * @returns File names on the storage connector, always starting with a slash */ async function writeAssets(session: any, websiteId: string, files: ConnectorFile[], connectorId?: string): Promise<string[]> { // Get the desired connector const storageConnector = await getStorageConnector(session, connectorId) // Clean up the path const cleanPathFiles = files.map(file => ({ ...file, path: file.path.replace('/assets/', '/'), // Remove the assets folder added by GrapesJS })) // Write the asset to the connector const result = await storageConnector.writeAssets( session, websiteId, cleanPathFiles, ) // Return the files URLs with the website id return files // Use the original path or the one returned by the connector .map(({ path }, idx) => result && result[idx] ? result[idx] : path) // Make it an absolute path with the website id and the connector id as query params //.map((path) => toAssetUrl(path, config.url, websiteId, connectorId)) } return router }