UNPKG

@silexlabs/silex

Version:

Free and easy website builder for everyone.

291 lines (256 loc) 11.1 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 fs from 'fs/promises' import { createWriteStream } from 'fs' import { ConnectorFile, StorageConnector, StatusCallback, ConnectorSession, toConnectorData, ConnectorFileContent} from './connectors' import { dirname, join } from 'path' import { ConnectorUser, WebsiteMeta, JobStatus, WebsiteId, ConnectorType, WebsiteMetaFileContent, WebsiteData, defaultWebsiteData, ConnectorOptions } from '../../types' import { userInfo } from 'os' import { requiredParam } from '../utils/validation' import { ServerConfig } from '../config' import { DEFAULT_WEBSITE_ID, WEBSITE_DATA_FILE, WEBSITE_META_DATA_FILE } from '../../constants' import { Readable } from 'stream' import { v4 as uuid } from 'uuid' import { fileURLToPath } from 'url' // Variables needed for jest tests if(!globalThis.__dirname) { // @ts-ignore globalThis.__dirname = dirname(process.cwd() + '/src/ts/server/connectors/FsStorage.ts') console.info('Redefining __dirname', globalThis.__dirname) } // Copy a folder recursively async function copyDir(src, dest) { await fs.mkdir(dest, { recursive: true }) const entries = await fs.readdir(src, { withFileTypes: true }) for (const entry of entries) { const srcPath = join(src, entry.name) const destPath = join(dest, entry.name) entry.isDirectory() ? await copyDir(srcPath, destPath) : await fs.copyFile(srcPath, destPath) } } type FsSession = ConnectorSession const USER_ICON = 'data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' height=\'1em\' viewBox=\'0 0 448 512\'%3E%3Cpath d=\'M304 128a80 80 0 1 0 -160 0 80 80 0 1 0 160 0zM96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM49.3 464H398.7c-8.9-63.3-63.3-112-129-112H178.3c-65.7 0-120.1 48.7-129 112zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3z\'/%3E%3C/svg%3E' const FILE_ICON = '/assets/laptop.png' interface FsOptions { path: string assetsFolder: string } export class FsStorage implements StorageConnector<FsSession> { connectorId = 'fs-storage' displayName = 'File system storage' icon = FILE_ICON disableLogout = true options: FsOptions connectorType = ConnectorType.STORAGE color = '#ffffff' background = '#006400' constructor(config: ServerConfig | null, opts: Partial<FsOptions>) { this.options = { path: join(__dirname, '..', '..', '..', '..', 'data'), assetsFolder: '/assets', ...opts, } this.initFs() } protected async initFs() { const stat = await fs.stat(this.options.path).catch(() => null) if (!stat) { // create data folder with a default website const id = DEFAULT_WEBSITE_ID await fs.mkdir(join(this.options.path, id, this.options.assetsFolder), { recursive: true }) await this.setWebsiteMeta({}, id, { name: 'Default website', connectorUserSettings: {} }) await this.updateWebsite({}, id, defaultWebsiteData) console.info(`> [FsStorage] Created ${id} in ${this.options.path}`) } } // ******************** // Job utils methods // ******************** private updateStatus(filesStatuses, status, statusCbk) { statusCbk && statusCbk({ message: `<p>Writing files:<ul><li>${filesStatuses.map(({file, message}) => `${file.path}: ${message}`).join('</li><li>')}</li></ul></p>`, status, }) } private initStatus(files) { return files.map(file => ({ file, message: 'Waiting', status: JobStatus.IN_PROGRESS, })) } // ******************** // Connector interface // ******************** getOptions(formData: object): ConnectorOptions { return {} } async getOAuthUrl(session: FsSession): Promise<null> { return null } async getLoginForm(session: FsSession, redirectTo: string): Promise<string | null> { return null } async getSettingsForm(session: FsSession, redirectTo: string): Promise<string | null> { return null } async isLoggedIn(session: FsSession): Promise<boolean> { return true } async setToken(session: FsSession, query: object): Promise<void> {} async logout(session: FsSession): Promise<void> {} async getUser(session: FsSession): Promise<ConnectorUser> { const { username, } = userInfo() return { name: username, picture: USER_ICON, storage: await toConnectorData(session, this), } } async setWebsiteMeta(session: any, id: string, data: WebsiteMetaFileContent): Promise<void> { const websiteId = requiredParam<WebsiteId>(id, 'website id') const content = JSON.stringify(data) const path = join(this.options.path, id, WEBSITE_META_DATA_FILE) await fs.writeFile(path, content) } async getWebsiteMeta(session: FsSession, id: WebsiteId): Promise<WebsiteMeta> { const websiteId = requiredParam<WebsiteId>(id, 'website id') // Get stats for website folder const fileStat = await fs.stat(join(this.options.path, websiteId)) const path = join(this.options.path, websiteId, WEBSITE_META_DATA_FILE) // Get meta file const content = await fs.readFile(path) const meta = await JSON.parse(content.toString()) // Return all meta return { websiteId, name: meta.name, imageUrl: meta.imageUrl, connectorUserSettings: meta.connectorUserSettings, createdAt: fileStat.birthtime, updatedAt: fileStat.mtime, } } // ******************** // Storage interface // ******************** async createWebsite(session: FsSession, meta: WebsiteMetaFileContent): Promise<WebsiteId> { const id = uuid() await fs.mkdir(join(this.options.path, id, this.options.assetsFolder), { recursive: true }) await this.setWebsiteMeta(session, id, meta) await this.updateWebsite(session, id, defaultWebsiteData) return id } async readWebsite(session: FsSession, websiteId: WebsiteId): Promise<WebsiteData> { const id = requiredParam<WebsiteId>(websiteId, 'website id') const path = join(this.options.path, id, WEBSITE_DATA_FILE) const content = await fs.readFile(path) return JSON.parse(content.toString()) } async updateWebsite(session: FsSession, websiteId: WebsiteId, data: WebsiteData): Promise<void> { const id = requiredParam<WebsiteId>(websiteId, 'website id') const path = join(this.options.path, id, WEBSITE_DATA_FILE) await fs.writeFile(path, JSON.stringify(data)) } async deleteWebsite(session: FsSession, websiteId: WebsiteId): Promise<void> { const id = requiredParam<WebsiteId>(websiteId, 'website id') const path = join(this.options.path, id) return fs.rmdir(path, { recursive: true }) } async duplicateWebsite(session: FsSession, websiteId: WebsiteId): Promise<void> { const newWebsiteId = uuid() const from = join(this.options.path, websiteId) const to = join(this.options.path, newWebsiteId) await copyDir(from, to) const meta = await this.getWebsiteMeta(session, websiteId) await this.setWebsiteMeta(session, newWebsiteId, { ...meta, name: `${meta.name} copy`, }) } async listWebsites(session: any): Promise<WebsiteMeta[]> { const list = await fs.readdir(this.options.path) return Promise.all(list.map(async fileName => { const websiteId = fileName as WebsiteId return this.getWebsiteMeta(session, websiteId) })) } async getAsset(session: FsSession, id: WebsiteId, path: string): Promise<ConnectorFile> { const fullPath = join(this.options.path, id, this.options.assetsFolder, path) const content = await fs.readFile(fullPath) return { path, content } } async writeAssets(session: FsSession, id: WebsiteId, files: ConnectorFile[], statusCbk?: StatusCallback): Promise<void> { return this.write(session, id, files, this.options.assetsFolder, statusCbk) } async write(session: FsSession, id: WebsiteId, files: ConnectorFile[], assetsFolder: string, statusCbk?: StatusCallback): Promise<void> { const filesStatuses = this.initStatus(files) let error: Error | null = null for (const fileStatus of filesStatuses) { const {file} = fileStatus const path = join(this.options.path, id, assetsFolder, file.path) if (typeof file.content === 'string' || Buffer.isBuffer(file.content)) { fileStatus.message = 'Writing' this.updateStatus(filesStatuses, JobStatus.IN_PROGRESS, statusCbk) try { await fs.writeFile(path, file.content) } catch(err) { fileStatus.message = `Error (${err})` this.updateStatus(filesStatuses, JobStatus.IN_PROGRESS, statusCbk) error = err continue } fileStatus.message = 'Success' this.updateStatus(filesStatuses, JobStatus.IN_PROGRESS, statusCbk) } else if (file.content instanceof Readable) { fileStatus.message = 'Writing' this.updateStatus(filesStatuses, JobStatus.IN_PROGRESS, statusCbk) const writeStream = createWriteStream(path) file.content.pipe(writeStream) await new Promise((resolve) => { writeStream.on('finish', () => { fileStatus.message = 'Success' this.updateStatus(filesStatuses, JobStatus.IN_PROGRESS, statusCbk) resolve(file) }) writeStream.on('error', err => { console.error('writeStream error', err) fileStatus.message = `Error (${err})` this.updateStatus(filesStatuses, JobStatus.IN_PROGRESS, statusCbk) error = err resolve(file) }) }) } else { console.error('Invalid file content', typeof file.content) throw new Error('Invalid file content: ' + typeof file.content) } } this.updateStatus(filesStatuses, error ? JobStatus.ERROR : JobStatus.SUCCESS, statusCbk) if(error) throw error } async deleteAssets(session: FsSession, id: WebsiteId, paths: string[]): Promise<void> { for (const path of paths) { await fs.unlink(join(this.options.path, id, path)) } } async readAsset(session: object, websiteId: string, fileName: string): Promise<ConnectorFileContent> { const id = requiredParam<WebsiteId>(websiteId, 'website id') const path = join(this.options.path, id, this.options.assetsFolder, fileName) return await fs.readFile(path) } }