auto-cms-server
Version:
Auto turn any webpage into editable CMS without coding.
754 lines (753 loc) • 25 kB
JavaScript
;
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('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>');
}
// 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);
});