UNPKG

auto-cms-server

Version:

Auto turn any webpage into editable CMS without coding.

754 lines (753 loc) 25 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = __importDefault(require("express")); const listening_on_1 = require("listening-on"); const env_1 = require("./env"); const timezone_date_ts_1 = require("timezone-date.ts"); const path_1 = require("path"); const session_1 = require("./session"); const fs_1 = require("fs"); const mime_detect_1 = require("mime-detect"); const format_1 = require("@beenotung/tslib/format"); const formidable_1 = require("formidable"); const bytes_1 = __importDefault(require("bytes")); const config_file_1 = require("./config-file"); const i18n_1 = require("./i18n"); const html_1 = require("./html"); const knex_1 = require("./knex"); const pkg_1 = require("./pkg"); const store_1 = require("./store"); const template_1 = require("./template"); const file_1 = require("./file"); const cookie_1 = require("./cookie"); const email_1 = require("./email"); (0, knex_1.setupKnex)(); if (env_1.config.enabled_multi_lang && env_1.config.enabled_easynmt) { (0, i18n_1.setupEasyNMT)(); } console.log(pkg_1.pkg.name, 'v' + pkg_1.pkg.version); console.log('Project Directory:', env_1.env.SITE_DIR); (0, config_file_1.setupConfigFile)(); let app = (0, express_1.default)(); app.use(session_1.sessionMiddleware); app.use(cookie_1.cookieMiddleware); if (env_1.config.enabled_auto_login) { app.use(session_1.autoLoginCMS); } app.use((req, res, next) => { (0, store_1.storeRequest)(req); next(); }); app.get('/auto-cms/status', (req, res, next) => { res.json({ enabled: req.session.auto_cms_enabled || false }); }); app.post('/auto-cms/login', express_1.default.urlencoded({ extended: false }), (req, res, next) => { if (req.body.password != env_1.env.AUTO_CMS_PASSWORD) { res.status(403); res.end('wrong password'); return; } req.session.auto_cms_enabled = true; req.session.save(); res.redirect('/auto-cms'); }); app.post('/auto-cms/logout', express_1.default.urlencoded({ extended: false }), (req, res, next) => { req.session.auto_cms_enabled = false; req.session.save(); res.redirect('/'); }); // list site files app.use((req, res, next) => { if (!req.session.auto_cms_enabled || req.method != 'GET' || !req.path.endsWith('__list__')) { return next(); } let file_path = (0, file_1.resolvePathname)({ site_dir, pathname: (0, path_1.dirname)(req.path) }); if ('error' in file_path) { res.status(500); next(file_path.error); return; } let dir = (0, path_1.dirname)(file_path.file); let dir_pathname = dir.replace(site_dir, ''); if (dir_pathname == '') { dir_pathname = '/'; } let title = escapeHTML(`File List of ${dir_pathname}`); res.write(/* html */ `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${title}</title> <style> .highlight { /* font-weight: bold; */ background-color: yellow; } body { font-family: monospace; } </style> </head> <body> <h1>${title}</h1> `); if (dir_pathname != '/') { let parent_pathname = (0, path_1.dirname)(dir_pathname); let parent_href = parent_pathname; if (!parent_href.endsWith('/')) { parent_href += '/'; } parent_href += '__list__'; res.write(/* html */ ` <nav><a href="${parent_href}">Back to ${parent_pathname}</a></nav> `); } res.write(/* html */ ` <ol>`); let filenames = (0, fs_1.readdirSync)(dir); let base_filename = (0, path_1.basename)(file_path.file).replaceAll(/_bk[0-9T]{15}/g, ''); for (let filename of filenames) { let href = (0, path_1.join)(dir.replace(site_dir, '/'), filename) .replaceAll('//', '/') .replaceAll('//', '/'); let file = (0, path_1.join)(dir, filename); let stat = (0, fs_1.statSync)(file); let type = '[F]'; if (stat.isDirectory()) { type = '[D]'; if (!href.endsWith('/')) { href += '/'; } href += '__list__'; } let className = filename.replaceAll(/_bk[0-9T]{15}/g, '') == base_filename ? 'highlight' : ''; let filename_html = escapeHTML(filename); res.write(/* html */ ` <li>${type} <a href="${href}" class="${className}">${filename_html}</a></li>`); } res.end(/* html */ ` </ol> </body> </html> `); }); // resolve implicit index.html or .html suffix app.options('/auto-cms/file', session_1.guardCMS, (req, res, next) => { let pathname = req.header('X-Pathname'); if (!pathname) { res.status(400); res.json({ error: 'missing X-Pathname in header' }); return; } let path = (0, file_1.resolvePathname)({ site_dir, pathname, mkdir: true }); if ('error' in path) { res.status(500); res.json({ error: path.error }); return; } pathname = path.file.replace(site_dir, ''); if (pathname == '') { pathname = '/'; } res.json({ pathname, exists: path.exists }); }); // save file (update html page, or upload image) let parse_html_middleware = express_1.default.text({ type: ['text/html', 'text/html; charset=utf-8'], limit: env_1.env.FILE_SIZE_LIMIT, defaultCharset: 'utf-8', }); let maxFileSize = bytes_1.default.parse(env_1.env.FILE_SIZE_LIMIT); let createUploadForm = (options) => new formidable_1.Formidable({ uploadDir: options.dir, filename: () => options.filename, multiples: false, allowEmptyFiles: false, maxFileSize, filter: part => part.name == 'file', }); app.put('/auto-cms/file', session_1.guardCMS, (req, res, next) => { let pathname = req.header('X-Pathname'); if (!pathname) { res.status(400); res.json({ error: 'missing X-Pathname in header' }); return; } // upload text/html if (req.header('Content-Type')?.includes('text/html') || req.header('Content-Type')?.includes('application/json')) { let path = (0, file_1.resolvePathname)({ site_dir, pathname, mkdir: true }); if ('error' in path) { res.status(500); res.json({ error: path.error }); return; } if (!path.exists) { res.status(400); res.json({ error: 'target file not found' }); return; } ; req.vars = path; next(); return; } // upload multipart form data let file = (0, path_1.resolve)((0, path_1.join)(site_dir, decodeURIComponent(pathname))); if (!file.startsWith(site_dir)) { res.status(400); res.json({ error: 'resolved pathname is out of the site directory' }); return; } if ((0, fs_1.existsSync)(file) && (0, fs_1.statSync)(file).isDirectory() && (0, fs_1.readdirSync)(file).length == 0) { (0, fs_1.rmdirSync)(file); } let dir = (0, path_1.dirname)(file); let filename = (0, path_1.basename)(file); (0, fs_1.mkdirSync)(dir, { recursive: true }); let form = createUploadForm({ dir, filename }); form.parse(req, (err, fields, files) => { if (err) { res.status(500); res.json({ error: String(err) }); return; } res.json({}); }); }, parse_html_middleware, express_1.default.json(), (req, res, next) => { let file = req.vars.file; if (file.endsWith('.json')) { saveLangDict({ file, json: req.body }); res.json({ message: 'saved to target file' }); return; } let content = req.body.trim(); if (!content) { res.status(400); res.json({ error: 'empty content' }); return; } saveHTMLFile(file, content + '\n'); res.json({ message: 'saved to target file' }); }); // copy file (restore html from backup version, or save as new page) app.put('/auto-cms/file/copy', session_1.guardCMS, (req, res, next) => { let from_pathname = req.header('X-From-Pathname'); if (!from_pathname) { res.status(400); res.json({ error: 'missing X-From-Pathname in header' }); return; } let to_pathname = req.header('X-To-Pathname'); if (!to_pathname) { res.status(400); res.json({ error: 'missing X-To-Pathname in header' }); return; } let from_path = (0, file_1.resolvePathname)({ site_dir, pathname: from_pathname, }); if ('error' in from_path) { res.status(500); res.json({ error: from_path.error }); return; } if (!from_path.exists) { res.status(400); res.json({ error: 'resolved from path does not exist' }); return; } let to_path = (0, file_1.resolvePathname)({ site_dir, pathname: to_pathname, mkdir: true, }); if ('error' in to_path) { res.status(500); res.json({ error: to_path.error }); return; } if (env_1.config.enabled_auto_backup && to_path.exists) { saveBackup(to_path.file); } (0, fs_1.copyFileSync)(from_path.file, to_path.file); res.json({ message: 'saved to target file' }); }); function saveHTMLFile(file, content) { if (env_1.config.enabled_auto_backup) { saveBackup(file); } (0, fs_1.writeFileSync)(file, content); saveLangFile(file + i18n_1.LangFileSuffix, content); } function saveLangFile(file, content) { let dict = (0, i18n_1.loadLangFile)(file) || {}; if (env_1.config.enabled_auto_backup) { saveBackup(file); } let matches = (0, i18n_1.extractWrappedText)(content); for (let match of matches) { let key = match; let word = dict[key]; if (!word) { let en = (0, html_1.decodeHTML)(key.slice(2, -2)); word = { en, zh_cn: '', zh_hk: '', ar: '', ja: '', ko: '' }; dict[key] = word; } } writeLangFile(file, dict); } function saveLangDict(options) { let { file, json } = options; let dict = i18n_1.langDictParser.parse(json); if (env_1.config.enabled_auto_backup) { saveBackup(file); } writeLangFile(file, dict); } function writeLangFile(file, dict) { let text = JSON.stringify(dict, null, 2); if (text == '{}') return; (0, fs_1.writeFileSync)(file, text + '\n'); autoTranslate({ file, dict }); } async function autoTranslate(options) { let { file, dict } = options; for (let [key, word] of Object.entries(dict)) { function save() { // load from dict in case it is updated manually in the meantime dict = JSON.parse((0, fs_1.readFileSync)(file).toString()); dict[key] = word; (0, fs_1.writeFileSync)(file, JSON.stringify(dict, null, 2) + '\n'); } if (!word.zh_cn) { let zh = await (0, i18n_1.en_to_zh)(word.en).catch(err => { // FIXME: failed to translate, need to find out why console.error('failed to translate into zh:', err); return ''; }); if (zh) { word.zh_cn = zh; save(); } } if (!word.zh_hk && word.zh_cn) { let zh = await (0, i18n_1.to_hk)(word.en, word.zh_cn).catch(err => { // FIXME: failed to translate, need to find out why console.error('failed to translate into traditional:', err); return ''; }); if (zh) { word.zh_hk = zh; save(); } } if (!word.en || (0, i18n_1.detectLang)(word.en) === 'zh') { let en = await (0, i18n_1.to_en)(word.zh_hk, word.zh_cn).catch(err => { // FIXME: failed to translate, need to find out why console.error('failed to translate into English:', err); return ''; }); if (en) { word.en = en; save(); } } if (!word.ja) { let ja = await (0, i18n_1.en_to_ja)(word.en).catch(err => { console.error('failed to translate into Japanese:', err); return ''; }); if (ja) { word.ja = ja; save(); } } if (!word.ko) { let ko = await (0, i18n_1.en_to_ko)(word.en).catch(err => { console.error('failed to translate into Korean:', err); return ''; }); if (ko) { word.ko = ko; save(); } } if (!word.ar) { let ar = await (0, i18n_1.en_to_ar)(word.en).catch(err => { // FIXME: failed to translate, need to find out why console.error('failed to translate into Arabic:', err); return ''; }); if (ar) { word.ar = ar; save(); } } } } function saveBackup(file) { let mtime; try { let stat = (0, fs_1.statSync)(file); mtime = stat.mtime; } catch (error) { // file not exist return; } let y = mtime.getFullYear(); let m = (0, format_1.format_2_digit)(mtime.getMonth() + 1); let d = (0, format_1.format_2_digit)(mtime.getDate()); let H = (0, format_1.format_2_digit)(mtime.getHours()); let M = (0, format_1.format_2_digit)(mtime.getMinutes()); let S = (0, format_1.format_2_digit)(mtime.getSeconds()); let ext = (0, path_1.extname)(file); let backup_file = file.slice(0, file.length - ext.length) + `_bk${y}${m}${d}T${H}${M}${S}${ext}`; (0, fs_1.renameSync)(file, backup_file); } app.delete('/auto-cms/file', session_1.guardCMS, (req, res, next) => { let pathname = req.header('X-Pathname'); if (!pathname) { res.status(400); res.json({ error: 'missing X-Pathname in header' }); return; } let path = (0, file_1.resolvePathname)({ site_dir, pathname }); if ('error' in path) { res.status(500); res.json({ error: path.error }); return; } if (!path.exists) { res.status(400); res.json({ error: 'target file not found' }); return; } (0, fs_1.unlinkSync)(path.file); res.json({ message: 'deleted file' }); }); app.get('/auto-cms/media-list', session_1.guardCMS, (req, res, next) => { let dir = scanMediaDir(site_dir); res.json({ dir }); }); let cms_transparent_grid_file = (0, path_1.resolve)(__dirname, '..', 'public', 'transparent-grid.svg'); app.get('/auto-cms/transparent-grid.svg', session_1.guardCMS, (req, res, next) => { res.setHeader('Content-Type', 'image/svg+xml'); res.sendFile(cms_transparent_grid_file); }); function getPkgPublicDir() { let devDir = (0, path_1.resolve)(__dirname, '..', 'public'); if ((0, fs_1.existsSync)(devDir)) return devDir; let prodDir = (0, path_1.resolve)(__dirname, '..', '..', 'public'); if ((0, fs_1.existsSync)(prodDir)) return prodDir; throw new Error('failed to resolve public directory of auto-cms package'); } let pkg_public_dir = getPkgPublicDir(); let cms_js_file = (0, path_1.resolve)(pkg_public_dir, 'auto-cms.js'); app.get('/auto-cms.js', session_1.guardCMS, (req, res, next) => { res.setHeader('Content-Type', 'application/javascript'); res.sendFile(cms_js_file); }); let cms_index_file = (0, path_1.resolve)(pkg_public_dir, 'auto-cms.html'); app.get('/auto-cms', (req, res, next) => { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.sendFile(cms_index_file); }); let multi_lang_file = (0, path_1.resolve)(pkg_public_dir, 'multi-lang.html'); app.get('/auto-cms/multi-lang', (req, res, next) => { res.sendFile(multi_lang_file); }); let multi_lang_js_file = (0, path_1.resolve)(pkg_public_dir, 'multi-lang.js'); app.get('/auto-cms/multi-lang.js', (req, res, next) => { res.sendFile(multi_lang_js_file); }); app.get('/auto-cms/langs', (req, res, next) => { res.json({ langs: i18n_1.Langs }); }); let site_dir = (0, path_1.resolve)(env_1.env.SITE_DIR); function scanMediaDir(dir) { let result = { url: dir.replace(site_dir, ''), name: (0, path_1.basename)(dir), files: [], dirs: [], total_media_count: 0, }; let filenames = (0, fs_1.readdirSync)(dir); for (let filename of filenames) { let file = (0, path_1.join)(dir, filename); let stat = (0, fs_1.statSync)(file); if (stat.isDirectory()) { let dir = scanMediaDir(file); if (dir.total_media_count > 0) { result.total_media_count += dir.total_media_count; result.dirs.push(dir); } } else if (stat.isFile()) { let mime = (0, mime_detect_1.detectFilenameMime)(filename); if (!mime.startsWith('image/') && !mime.startsWith('video/') && !mime.startsWith('audio/')) { continue; } let url_dir = dir.replace(site_dir, ''); if (url_dir == '') { url_dir = '/'; } result.files.push({ dir: url_dir, filename, size: (0, format_1.format_byte)(stat.size), url: (0, path_1.join)(url_dir, filename), mimetype: mime, }); result.total_media_count++; } } return result; } app.post('/contact', express_1.default.urlencoded({ extended: false }), express_1.default.json(), (req, res, next) => { let error = ''; try { let contact = (0, store_1.storeContact)(req); let entries = Object.entries(contact); let text = ``; let html = ``; for (let [_key, value] of entries) { if (value == null) continue; let key = _key; let label = key .replace(/_/g, ' ') .replace(/\b\w/g, char => char.toUpperCase()); if (key == 'submit_time' && typeof value === 'number') { let timezone = env_1.env.TIMEZONE_HOUR; let date = new timezone_date_ts_1.TimezoneDate(value, { timezone }); value = `${date.toLocaleString()} (GMT+${timezone})`; } if (key == 'lang') { value = i18n_1.Langs.find(lang => lang.code == value)?.name || value; } if (value && typeof value === 'object') { value = JSON.stringify(value); } text += `${label}: ${value}\n`; if (key == 'email') { html += `<p>${label}: <a href="mailto:${value}">${value}</a></p>`; } else { html += `<p>${label}: ${value}</p>`; } } let site_name = env_1.env.ORIGIN.split('://').pop(); (0, email_1.sendEmail)({ from: env_1.env.EMAIL_USER, to: env_1.env.EMAIL_USER, subject: 'Contact Form Submission to ' + site_name, text, html, }).catch(err => { console.error('failed to send email:', err); }); } catch (e) { error = String(e); } if (req.headers.accept?.includes('json')) { if (error) { res.status(400); res.json({ error }); } else { res.json({ code: 200, ok: true, success: true }); } } else { if (env_1.env.SUBMIT_CONTACT_RESULT_PAGE == 'default') { res.end(/* html */ `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Submitted</title> </head> <body> <p>Your submission has been received.</p> ${error ? `<pre><code>${escapeHTML(error)}</code></pre>` : ''} <p>Back to <a href="/">home page</a>.</p> </body> </html> `); } else { res.sendFile((0, path_1.resolve)(site_dir, env_1.env.SUBMIT_CONTACT_RESULT_PAGE)); } } }); function escapeHTML(text) { return text .replaceAll('&', '&amp;') .replaceAll('<', '&lt;') .replaceAll('>', '&gt;'); } // GET site file app.use((req, res, next) => { if (req.method !== 'GET') { next(); return; } // "/_next/image?url=abc.jpeg&w=80&q=50" -> "/_next/image/url=abc.jpeg&w=80&q=50" if (req.path === '/_next/image') { let dir = (0, path_1.join)(env_1.env.SITE_DIR, '/_next/image'); let filenames = (0, fs_1.readdirSync)(dir); find_file: for (let filename of filenames) { let params = new URLSearchParams(filename); for (let [key, value] of params.entries()) { if (req.query[key] != value) { continue find_file; } } let file = (0, path_1.resolve)((0, path_1.join)(dir, filename)); let url = params.get('url').toLowerCase(); let ext = (0, path_1.extname)(url); if (ext == '.jpg') ext = '.jpeg'; if (ext) res.setHeader('Content-Type', 'image/' + ext.replace('.', '')); sendFile(res, file); return; } } try { let path = (0, file_1.resolvePathname)({ site_dir, pathname: req.path }); if ('error' in path) { next(path.error); return; } if (!path.exists) { next(); return; } let file = path.file; let ext = (0, path_1.extname)(file); if (ext == '.html') { let content = (0, fs_1.readFileSync)(file); sendHTML(req, res, content, file); return; } if (ext == '') { let content = (0, fs_1.readFileSync)(file); if (isHTMLInBuffer(content)) { sendHTML(req, res, content, file); return; } sendBuffer(res, content); return; } sendFile(res, file); } catch (error) { next(error); } }); function get404File() { let file = (0, path_1.resolve)(site_dir, '404.html'); if ((0, fs_1.existsSync)(file)) return file; file = (0, path_1.resolve)(site_dir, '404/index.html'); if ((0, fs_1.existsSync)(file)) return file; file = (0, path_1.resolve)(site_dir, '404'); if ((0, fs_1.existsSync)(file)) return file; file = (0, path_1.resolve)(site_dir, 'index.html'); if ((0, fs_1.existsSync)(file)) return file; return null; } // 404 page app.use((req, res, next) => { res.status(404); let file = get404File(); if (file) { let content = (0, fs_1.readFileSync)(file); sendHTML(req, res, content, file); } else { next(); } }); let prefix_doctype = '<!DOCTYPE html>'.toLowerCase(); let prefix_html = '<html'.toLowerCase(); function isHTMLInBuffer(content) { return (isBufferStartsWith(content, prefix_doctype) || isBufferStartsWith(content, prefix_html)); } function isBufferStartsWith(content, prefix) { if (content.length < prefix.length) return false; return content.subarray(0, prefix.length).toString().toLowerCase() == prefix; } // required to apply `sessionMiddleware` and `cookieMiddleware` before calling this function function sendHTML(req, res, content, file) { res.setHeader('Content-Type', 'text/html; charset=utf-8'); if (req.session.auto_cms_enabled) { res.write(content); res.end('<script src="/auto-cms.js"></script>'); return; } let lang = null; if (env_1.config.enabled_multi_lang) { lang = req.cookies.lang; if (!lang) { lang = env_1.env.AUTO_CMS_DEFAULT_LANG; res.cookie('lang', lang); } } if (env_1.config.enabled_template) { content = (0, template_1.applyTemplates)({ site_dir, html: content.toString(), file, lang, }); } if (lang) { content = (0, i18n_1.translateHTML)({ html: content.toString(), file: file + i18n_1.LangFileSuffix, lang, }); } res.end(content); } function sendBuffer(res, content) { // FIXME need to set content-type manually? res.write(content); res.end(); } function sendFile(res, file) { // FIXME need to set content-type manually? res.sendFile(file); } let port = env_1.env.PORT; app.listen(port, () => { (0, listening_on_1.print)(port); });