tinymdserv
Version:
Tiny Markdown Server
383 lines (316 loc) • 11.8 kB
JavaScript
import express from 'express';
import os from 'os';
import path from 'path';
import https from 'https';
import http from 'http';
import { marked } from 'marked';
import * as fs from 'node:fs';
import yargs from 'yargs';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { version } = require('./package.json');
//////////////////////
// Global variables //
//////////////////////
let docs_path;
let quiet = false;
///////////////////
// Main function //
///////////////////
function main() {
// SSL certificate file path.
const key_path = 'server.key';
const cert_path = 'server.crt';
// Check if SSL files exist
const use_https = fs.existsSync(key_path) && fs.existsSync(cert_path);
const argv = yargs(process.argv.slice(2))
.option('port', {
alias: 'p',
type: 'number',
description: 'Set port number',
default: (use_https ? 443 : 80),
})
.option('dir', {
alias: 'd',
type: 'string',
description: 'Set document path',
default: './docs',
})
.option('file', {
alias: 'f',
type: 'string',
description: 'Set document default name',
default: 'index.md',
})
.option('quiet', {
alias: 'q',
type: 'boolean',
description: 'Activate quiet mode',
})
.help()
.argv;
if (yargs.version) {
console.log(`Version: ${version}`);
return;
}
const app = express();
// Set docs path and quiet mode.
docs_path = path.resolve(argv.dir);
quiet = argv.quiet;
if (!argv.quiet) {
console.log('Version:', version);
console.log('Port number:', argv.port);
console.log('Directory:', docs_path);
console.log('Default name:', argv.file);
}
app.set('views', docs_path);
app.set('view engine', 'ejs');
app.get('/', (req, res) => {
res.redirect(`/${argv.file}`);
});
app.get('*.md', doc_handler);
app.get('*.__datetime__', datetime_handler);
app.get('/search', search_handler);
app.get('*.mp4', mp4_handler);
app.use(express.static(docs_path))
app.get('*', default_handler);
// Start server
if (use_https) {
// HTTPS server
https.createServer({
key: fs.readFileSync(key_path),
cert: fs.readFileSync(cert_path)
}, app).listen(argv.port, () => {
const address = 'https://' + get_ip_address() + (argv.port == 443 ? '' : `:${argv.port}`) + '/';
if (!argv.quiet)
console.log(`HTTPS Server started on ${address}`);
});
} else {
// HTTP server
http.createServer(app).listen(argv.port, () => {
const address = 'http://' + get_ip_address() + (argv.port == 80 ? '' : `:${argv.port}`) + '/';
if (!argv.quiet)
console.log(`HTTP Server started on ${address}`);
});
}
};
main();
///////////////////////
// Handler functions //
///////////////////////
const datetime_option = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
const escapeMap = {
'\\': '\',
'<': '<',
'_': '_',
'*': '*',
'`': '`',
"'": ''',
'"': '"'
};
const unescapeMap = Object.entries(escapeMap).reduce(
(acc, [key, value]) => ({ ...acc, [value]: key }),
{}
);
function escapeMath(text) {
const regex1 = /((^|(?<=[^\\$]))\$)([^$\n]+?)((?<=[^\\$])\$)/g;
const regex2 = /((^|(?<=[^\\$]))\$\$)([^$]+?)((?<=[^\\$])\$\$)/g;
const result = text.replace(regex1, (_match, _del1, _pref, content, _del2) => {
let escaped = content;
Object.entries(escapeMap).forEach(([char, replacement]) => {
escaped = escaped.replace(new RegExp(`\\${char}`, 'g'), replacement);
});
return `$${escaped}$`;
});
return result.replace(regex2, (_match, _del1, _pref, content, _del2) => {
let escaped = content;
Object.entries(escapeMap).forEach(([char, replacement]) => {
escaped = escaped.replace(new RegExp(`\\${char}`, 'g'), replacement);
});
return `$$${escaped}$$`;
});
}
function unescapeMath(text) {
const regex1 = /((^|(?<=[^\\$]))\$)([^$\n]+?)((?<=[^\\$])\$)/g;
const regex2 = /((^|(?<=[^\\$]))\$\$)([^$]+?)((?<=[^\\$])\$\$)/g;
const result = text.replace(regex1, (_match, _del1, _pref, content, _del2) => {
let unescaped = content;
Object.entries(unescapeMap).forEach(([placeholder, char]) => {
unescaped = unescaped.replace(new RegExp(placeholder, 'g'), char);
});
return `$${unescaped}$`;
});
return result.replace(regex2, (_match, _del1, _pref, content, _del2) => {
let unescaped = content;
Object.entries(unescapeMap).forEach(([placeholder, char]) => {
unescaped = unescaped.replace(new RegExp(placeholder, 'g'), char);
});
return `$$${unescaped}$$`;
});
}
function doc_handler(req, res) {
try {
const req_url = decodeURIComponent(req.url);
const params = new URL('http://127.0.0.1' + req_url).searchParams;
const page = params.get('page');
const file_path = req_url.split('?')[0];
if (!quiet)
console.log(`${(new Date()).toLocaleString('en-US', datetime_option)}, ${req.socket.remoteAddress}, ${req_url}`);
let doc = fs.readFileSync(docs_path + file_path, 'utf8');
let title = doc.match(/^# .*?(?=\r?\n)/);
title = title ? title[0].substring(1).trim() : file_path;
doc = escapeMath(doc);
marked.use({
renderer: {
code({text, lang, escaped}) {
if (lang === 'mermaid')
return `<div class="text-center"><pre class="mermaid">${text}</pre></div>`;
else
return `<pre><code class="${lang}">${text}</code></pre>`;
}
}
});
let content = marked(doc, { "mangle": false, headerIds: false });
content = unescapeMath(content);
let dict_params = {};
params.forEach((value, key) => {
dict_params[key] = value;
});
res.render('template.ejs',
{
"title": title,
"content": content,
"page": page,
"params": dict_params
});
} catch (error) {
if (!quiet)
console.log(error);
res.status(500).send('Internal error!');
}
}
function datetime_handler(req, res) {
try {
const req_url = decodeURIComponent(req.url);
const file_path = req_url.substring(0, req_url.length - '.__datetime__'.length);
fs.stat(docs_path + file_path, function (err, stats) {
if (err) {
res.status(400).send('Bad request!');
} else {
res.send(stats.mtime.toLocaleString());
}
});
} catch (error) {
if (!quiet)
console.log(error);
res.status(500).send('Internal error!');
}
}
function search_handler(req, res) {
try {
const req_url = decodeURIComponent(req.url);
const query = new URL('http://127.0.0.1' + req_url).searchParams.get('q');
let file_list = (query == null) ? [] : search_files(docs_path, query);
res.render('search.ejs', { "title": query, "content": file_list });
} catch (error) {
if (!quiet)
console.log(error);
res.status(500).send('Internal error!');
}
}
function search_files(directory, query) {
const files_matching = [];
const prefix_length = directory.length;
const search_string = query.toLowerCase()
function search(directory) {
const files = fs.readdirSync(directory);
for (const file of files) {
const file_path = path.join(directory, file);
if (fs.statSync(file_path).isDirectory()) {
search(file_path);
} else {
const extension = path.extname(file_path).toLowerCase();
if (extension !== '.md' && extension !== '.html')
continue;
const content = fs.readFileSync(file_path, 'utf8').toLowerCase();
if (!content.includes(search_string))
continue;
files_matching.push(file_path.substring(prefix_length).replace(/\\/g, '/'));
}
}
}
search(directory);
return files_matching;
}
function mp4_handler(req, res) {
try {
const req_url = decodeURIComponent(req.url);
if (!quiet)
console.log(`${(new Date()).toLocaleString('en-US', datetime_option)}, ${req.socket.remoteAddress}, ${req_url}`);
const video_path = docs_path + req_url;
const video_size = fs.statSync(video_path).size;
const range = req.headers.range;
const CHUNK_SIZE = 10 ** 6; // 1MB
res.setHeader('Content-Type', 'video/mp4');
res.setHeader('Content-Disposition', 'inline');
if (range) {
const [start, end] = range.replace(/bytes=/, '').split('-');
const start_pos = parseInt(start);
let end_pos = parseInt(end);
end_pos = isNaN(end_pos) ? video_size - 1 : end_pos;
const content_length = end_pos - start_pos + 1;
res.status(206).header({
'Content-Range': `bytes ${start_pos}-${end_pos}/${video_size}`,
'Accept-Ranges': 'bytes',
'Content-Length': content_length,
});
const video_stream = fs.createReadStream(video_path, { start: start_pos, end: end_pos });
video_stream.pipe(res);
} else {
res.status(200).header({
'Content-Length': video_size,
});
const video_stream = fs.createReadStream(video_path, { highWaterMark: CHUNK_SIZE });
video_stream.pipe(res);
}
} catch (error) {
if (!quiet)
console.log(error);
res.status(500).send(`Internal error!: ${error}.`);
}
}
function default_handler(req, res) {
const req_url = decodeURIComponent(req.url);
const postfix = [".md", "index.md", "/index.md"]
for (let i = 0; i < postfix.length; i++) {
try {
fs.accessSync(docs_path + req_url + postfix[i], fs.constants.F_OK);
res.redirect(req_url + postfix[i]);
return;
} catch (error) {
}
}
res.status(404).send('404 Not Found!');
}
function get_ip_address() {
const networkInterfaces = os.networkInterfaces();
const ipAddresses = [];
Object.keys(networkInterfaces).forEach((interfaceName) => {
const interfaces = networkInterfaces[interfaceName];
for (const iface of interfaces) {
if (!iface.internal && iface.family === 'IPv4') {
ipAddresses.push(iface.address);
}
}
});
return ipAddresses.length >= 1 ? ipAddresses[0] : '127.0.0.1';
}