tinymdserv
Version:
Tiny Markdown Server
358 lines (299 loc) • 11 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('/search', search_handler);
app.use((req, res, next) => {
if (req.path.endsWith('.md')) {
doc_handler(req, res);
} else if (req.path.endsWith('.__datetime__')) {
datetime_handler(req, res);
} else if (req.path.endsWith('.mp4')) {
mp4_handler(req, res);
} else {
next();
}
});
app.use(express.static(docs_path))
app.use((req, res, next) => {
default_handler(req, res);
});
// 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
};
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;
// 페이지 파라메터가 있으면 문서를 페이지로 나눈다
if (page !== null) {
const pages = doc.split('\n---\n');
const pageIndex = parseInt(page);
if (pageIndex >= 0 && pageIndex < pages.length) {
doc = pages[pageIndex];
// 페이지별 타이틀 추출 (각 페이지의 첫 번째 헤더 사용)
const pageTitle = doc.match(/^# .*?(?=\r?\n)/);
if (pageTitle) {
title = pageTitle[0].substring(1).trim();
}
} else {
// 유효하지 않은 페이지 인덱스인 경우 404 에러
res.status(404).send('Page not found!');
return;
}
}
doc = doc
.replace(/\$\$([\s\S]+?)\$\$/g, (_, inner) => `<pre><code="latex_math_2">${inner}</code></pre>`)
.replace(/\$([^\n\r$]+?)\$/g, (_, inner) => `<pre><code="latex_math_1">${inner}</code></pre>`);
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>`;
}
}
});
doc = marked(doc, { "mangle": false, headerIds: false });
doc = doc
.replace(/<pre><code="latex_math_1">([^\n\r$]+?)<\/code><\/pre>/g, (_, inner) => `$${inner}$`)
.replace(/<pre><code="latex_math_2">([\s\S]+?)<\/code><\/pre>/g, (_, inner) => `$$${inner}$$`)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
let dict_params = {};
params.forEach((value, key) => {
dict_params[key] = value;
});
res.render('template.ejs',
{
"title": title,
"content": doc,
"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';
}