2 * Copyright (C) 2018-2022 Nick Downing <nick@ndcode.org>
3 * SPDX-License-Identifier: MIT
5 * Permission is hereby granted, free of charge, to any person obtaining a copy
6 * of this software and associated documentation files (the "Software"), to
7 * deal in the Software without restriction, including without limitation the
8 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
9 * sell copies of the Software, and to permit persons to whom the Software is
10 * furnished to do so, subject to the following conditions:
12 * The above copyright notice and this permission notice shall be included in
13 * all copies or substantial portions of the Software.
15 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
24 import JSONCache from '@ndcode/json_cache'
25 import JSONCacheRW from '@ndcode/json_cache_rw'
26 import JSTCache from '@ndcode/jst_cache'
27 import LessCSSCache from '@ndcode/less_css_cache'
28 import MenuCache from '@ndcode/menu_cache'
29 import MinCSSCache from '@ndcode/min_css_cache'
30 import MinJSCache from '@ndcode/min_js_cache'
31 import MinHTMLCache from '@ndcode/min_html_cache'
32 import MinSVGCache from '@ndcode/min_svg_cache'
33 import Problem from './Problem.mjs'
34 import Resources from './Resources.mjs'
35 import SassCSSCache from '@ndcode/sass_css_cache'
36 import TextCache from '@ndcode/text_cache'
37 import ZipCache from '@ndcode/zip_cache'
38 import assert from 'assert'
40 import fsPromises from 'fs/promises'
41 import jst_server from './index.mjs'
44 constructor(resources, root, options/*, prev_site*/) {
45 this.resources = resources
47 this.options = Object.assign(
51 '.css': 'text/css; charset=utf-8',
52 '.html': 'text/html; charset=utf-8',
53 '.ico': 'image/x-icon',
55 '.jpeg': 'image/jpeg',
56 '.js': 'application/javascript; charset=utf-8',
57 '.json': 'application/json; charset=utf-8',
59 '.pdf': 'application/pdf',
61 '.svg': 'image/svg+xml',
62 '.ttf': 'application/octet-stream',
64 '.woff2': 'font/woff2',
65 '.xml': 'text/xml; charset=utf-8'
67 certbot_webroot: '/var/www/html'
72 this.json_cache = undefined
73 this.json_cache_rw = undefined
74 this.jst_cache = undefined
75 this.less_css_cache = undefined
76 this.menu_cache = undefined
77 this.min_css_cache = undefined
78 this.min_js_cache = undefined
79 this.min_html_cache = undefined
80 this.min_svg_cache = undefined
81 this.text_cache = undefined
82 this.zip_cache = undefined
84 this.socket_io_connect_listeners = [] // later will use this for destruction
88 assert(this.json_cache === undefined)
89 this.json_cache = await this.resources.ref(
91 async () => new JSONCache(true)
94 assert(this.json_cache_rw === undefined)
95 this.json_cache_rw = await this.resources.ref(
97 async () => new JSONCacheRW(true)
100 assert(this.jst_cache === undefined)
101 this.jst_cache = await this.resources.ref(
102 `jst_cache:${this.root}`,
103 async () => new JSTCache(this.root, {_jst_server: jst_server}, true)
106 assert(this.less_css_cache === undefined)
107 this.less_css_cache = await this.resources.ref(
108 `less_css_cache:${this.root}`,
109 async () => new LessCSSCache(this.root, true)
112 assert(this.menu_cache === undefined)
113 this.menu_cache = await this.resources.ref(
115 async () => new MenuCache(true)
118 assert(this.min_css_cache === undefined)
119 this.min_css_cache = await this.resources.ref(
121 async () => new MinCSSCache(true)
124 assert(this.min_js_cache === undefined)
125 this.min_js_cache = await this.resources.ref(
127 async () => new MinJSCache(true)
130 assert(this.min_html_cache === undefined)
131 this.min_html_cache = await this.resources.ref(
133 async () => new MinHTMLCache(true)
136 assert(this.min_svg_cache === undefined)
137 this.min_svg_cache = await this.resources.ref(
139 async () => new MinSVGCache(true)
142 assert(this.sass_css_cache === undefined)
143 this.sass_css_cache = await this.resources.ref(
144 `sass_css_cache:${this.root}`,
145 async () => new SassCSSCache(this.root, true)
148 assert(this.text_cache === undefined)
149 this.text_cache = await this.resources.ref(
151 async () => new TextCache(true)
154 assert(this.zip_cache === undefined)
155 this.zip_cache = await this.resources.ref(
157 async () => new ZipCache(true)
162 assert(this.json_cache !== undefined)
163 await this.resources.unref('json_cache')
165 assert(this.json_cache_rw !== undefined)
166 await this.resources.unref('json_cache_rw')
168 assert(this.jst_cache !== undefined)
169 await this.resources.unref(`jst_cache:${this.root}`)
171 assert(this.less_css_cache !== undefined)
172 await this.resources.unref(`less_css_cache:${this.root}`)
174 assert(this.menu_cache !== undefined)
175 await this.resources.unref('menu_cache')
177 assert(this.min_css_cache !== undefined)
178 await this.resources.unref('min_css_cache')
180 assert(this.min_js_cache !== undefined)
181 await this.resources.unref('min_js_cache')
183 assert(this.min_html_cache !== undefined)
184 await this.resources.unref('min_html_cache')
186 assert(this.min_svg_cache !== undefined)
187 await this.resources.unref('min_svg_cache')
189 assert(this.sass_css_cache !== undefined)
190 await this.resources.unref(`sass_css_cache:${this.root}`)
192 assert(this.text_cache !== undefined)
193 await this.resources.unref('text_cache')
195 assert(this.zip_cache !== undefined)
196 await this.resources.unref('zip_cache')
200 assert(this.json_cache !== undefined)
201 this.json_cache.kick()
203 assert(this.json_cache_rw !== undefined)
204 this.json_cache_rw.kick()
206 assert(this.jst_cache !== undefined)
207 this.jst_cache.kick()
209 assert(this.less_css_cache !== undefined)
210 this.less_css_cache.kick()
212 assert(this.menu_cache !== undefined)
213 this.menu_cache.kick()
215 assert(this.min_css_cache !== undefined)
216 this.min_css_cache.kick()
218 assert(this.min_js_cache !== undefined)
219 this.min_js_cache.kick()
221 assert(this.min_html_cache !== undefined)
222 this.min_html_cache.kick()
224 assert(this.min_svg_cache !== undefined)
225 this.min_svg_cache.kick()
227 assert(this.sass_css_cache !== undefined)
228 this.sass_css_cache.kick()
230 assert(this.text_cache !== undefined)
231 this.text_cache.kick()
233 assert(this.zip_cache !== undefined)
234 this.zip_cache.kick()
237 serve_internal(response, status, mime_type, caching, data) {
238 response.statusCode = status
239 // html files will be direct recipient of links/bookmarks so can't have
240 // a long lifetime, other files like css or images are often large files
241 // and won't change frequently (but we'll need cache busting eventually)
242 if (caching && mime_type !== this.options.mime_type['.html'])
243 response.setHeader('Cache-Control', 'max-age=3600')
244 response.setHeader('Content-Type', mime_type)
245 response.setHeader('Content-Length', data.length)
249 serve(env, status, data, from) {
251 `${env.parsed_url.host} serving ${env.parsed_url.pathname} size ${data.length} from ${from}`
253 this.serve_internal(env.response, status, env.mime_type, env.caching, data)
256 redirect(env, pathname, message) {
258 `${env.parsed_url.host} redirecting ${env.parsed_url.pathname} to ${pathname}`
260 let location = pathname + (env.parsed_url.search || '')
261 env.response.statusCode = 301
262 env.response.setHeader('Location', location)
266 this.options.mime_types['.html'],
271 <meta http-equiv="content-type" content="text/html;charset=utf-8">
272 <title>301 Moved Permanently</title>
274 <body style="font-family: sans-serif, serif, monospace; font-size: 20px;">
275 <h2>301 Moved Permanently</h2>
276 The document has moved <a href="${location}">here</a>.
285 async internal_ensure_dir(pathname) {
287 await fsPromises.mkdir(pathname)
288 console.log('create directory', pathname)
291 if (err.code !== 'EEXIST') // should check error type
297 return /*await*/ this.json_cache.get(this.root + pathname)
301 return /*await*/ this.jst_cache.get(this.root + pathname)
304 get_less_css(pathname) {
305 return /*await*/ this.less_css_cache.get(this.root + pathname)
309 return /*await*/ this.menu_cache.get(this.root + pathname)
312 get_min_css(pathname) {
313 return /*await*/ this.min_css_cache.get(this.root + pathname)
316 get_min_html(pathname) {
317 return /*await*/ this.min_html_cache.get(this.root + pathname)
320 get_min_js(pathname) {
321 return /*await*/ this.min_js_cache.get(this.root + pathname)
324 get_min_svg(pathname) {
325 return /*await*/ this.min_svg_cache.get(this.root + pathname)
328 get_sass_css(pathname) {
329 return /*await*/ this.sass_css_cache.get(this.root + pathname)
333 return /*await*/ this.text_cache.get(this.root + pathname)
337 return /*await*/ this.zip_cache.get(this.root + pathname)
340 async ensure_dir(pathname) {
341 return /*await*/ this.internal_ensure_dir(this.root + pathname)
344 // this is for read/write JSON files
345 // they will not be reloaded from disk if modified
346 async read_json(pathname, default_value) {
347 return /*await*/ this.json_cache_rw.read(
348 this.root + pathname,
353 async write_json(pathname, value, timeout) {
354 return /*await*/ this.json_cache_rw.write(
355 this.root + pathname,
361 async modify_json(pathname, default_value, modify_func, timeout) {
362 return /*await*/ this.json_cache_rw.modify(
363 this.root + pathname,
370 async serve_jst(env, pathname, ...args) {
373 template = await this.jst_cache.get(pathname)
376 if (!(err instanceof Error) || err.code !== 'ENOENT')
381 await template(env, ...args)
385 async serve_less_css(env, pathname) {
386 if (pathname.slice(-9) !== '.css.less')
391 data = await this.less_css_cache.get(pathname)
394 if (!(err instanceof Error) || err.code !== 'ENOENT')
398 this.serve(env, 200, data, 'less_css')
402 async serve_min_css(env, pathname) {
403 if (pathname.slice(-8) !== '.css.min')
408 data = await this.min_css_cache.get(pathname)
411 if (!(err instanceof Error) || err.code !== 'ENOENT')
415 this.serve(env, 200, data, 'min_css')
419 async serve_min_html(env, pathname) {
420 if (pathname.slice(-9) !== '.html.min')
425 data = await this.min_html_cache.get(pathname)
428 if (!(err instanceof Error) || err.code !== 'ENOENT')
432 this.serve(env, 200, data, 'min_html')
436 async serve_min_js(env, pathname) {
437 if (pathname.slice(-7) !== '.js.min')
442 data = await this.min_js_cache.get(pathname)
445 if (!(err instanceof Error) || err.code !== 'ENOENT')
449 this.serve(env, 200, data, 'min_js')
453 async serve_min_svg(env, pathname) {
454 if (pathname.slice(-8) !== '.svg.min')
459 data = await this.min_svg_cache.get(pathname)
462 if (!(err instanceof Error) || err.code !== 'ENOENT')
466 this.serve(env, 200, data, 'min_svg')
470 async serve_sass_css(env, pathname) {
472 pathname.slice(-9) !== '.css.sass' &&
473 pathname.slice(-9) !== '.css.scss'
479 data = await this.sass_css_cache.get(pathname)
482 if (!(err instanceof Error) || err.code !== 'ENOENT')
486 this.serve(env, 200, data, 'sass_css')
490 async serve_fs(env, pathname) {
491 // see serve_internal()
492 // since the file may be huge we need to cache it for as long as reasonable
493 if (this.options.caching)
494 env.response.setHeader('Cache-Control', 'max-age=86400')
495 env.response.setHeader('Content-Type', env.mime_type)
497 // see https://dev.to/abdisalan_js/how-to-code-a-video-streaming-server-using-nodejs-2o0
499 let range = env.request.headers.range;
500 if (range !== undefined) {
503 stats = await fsPromises.stat(pathname)
506 if (!(err instanceof Error) || err.code !== 'ENOENT')
512 // Example: "bytes=32324-"
513 let start = Number(range.replace(/\D/g, ''))
514 let end = Math.min(start + 1048576, stats.size)
518 `${env.parsed_url.host} streaming ${env.parsed_url.pathname} partial ${start}-${end}/${stats.size}`
522 env.response.statusCode = 206 // partial content
523 env.response.setHeader(
525 `bytes ${start}-${end - 1}/${stats.size}`
527 env.response.setHeader('Accept-Ranges', 'bytes')
528 env.response.setHeader('Content-Length', end - start)
530 // create video read stream for this particular chunk
531 stream = fs.createReadStream(pathname, {start: start, end: end - 1})
536 `${env.parsed_url.host} streaming ${env.parsed_url.pathname}`
539 // see serve_internal()
540 env.response.statusCode = 200
541 stream = fs.createReadStream(pathname)
544 return /*await*/ new Promise(
545 (resolve, reject) => {
549 //console.log(`error: ${err.message}`)
550 if (!(err instanceof Error) || err.code !== 'ENOENT')
558 //console.log(`data: ${data.length} bytes`)
559 env.response.write(data)
574 async serve_zip(env, zipname, pathname) {
577 zip = await this.zip_cache.get(zipname)
580 if (!(err instanceof Error) || err.code !== 'ENOENT')
584 if (!Object.prototype.hasOwnProperty.call(zip, pathname))
586 this.serve(env, 200, zip[pathname], 'zip')
590 async serve_file(env, pathname) {
592 !await this.serve_jst(env, pathname + '.jst') &&
593 !await this.serve_less_css(env, pathname + '.less') &&
594 !await this.serve_min_css(env, pathname + '.min') &&
595 !await this.serve_min_html(env, pathname + '.min') &&
596 !await this.serve_min_js(env, pathname + '.min') &&
597 !await this.serve_min_svg(env, pathname + '.min') &&
598 !await this.serve_sass_css(env, pathname + '.sass') &&
599 !await this.serve_sass_css(env, pathname + '.scss') &&
600 !await this.serve_fs(env, pathname)
604 `File "${pathname}" not found.`,
609 async serve_dir(env, pathname, components) {
610 if (await this.serve_jst(env, pathname + '.dir.jst', pathname, components))
615 stats = await fsPromises.stat(pathname)
618 if (!(error instanceof Error) || error.code !== 'ENOENT')
622 `Directory "${pathname}" not found.`,
626 if (!stats.isDirectory())
629 `Path "${pathname}" not directory.`,
632 return /*await*/ this.serve_path(env, pathname, components)
635 async serve_path(env, pathname, components) {
636 //console.log(`serve_path ${pathname} ${components}`)
637 if (components.length === 0) {
638 // directory without trailing slash
639 this.redirect(env, env.parsed_url.pathname + '/index.html')
643 if (components[0].length === 0) {
644 if (components.length > 1)
647 `Path "${pathname}" followed by empty directory name.`,
650 // directory with trailing slash
651 this.redirect(env, env.parsed_url.pathname + 'index.html')
656 components[0].charAt(0) === '.' ||
657 components[0].charAt(0) === '_'
661 `Path "${pathname}" followed by bad component "${components[0]}".`,
665 let i = components[0].lastIndexOf('.')
667 i = components[0].length
668 let extension = components[0].slice(i)
670 pathname = `${pathname}/${components[0]}`
672 extension.length !== 0 &&
673 Object.prototype.hasOwnProperty.call(this.options.mime_types, extension)
675 if (components.length > 1)
678 `Directory "${pathname}" has non-directory extension "${extension}".`,
681 return /*await*/ this.serve_file(env, pathname)
683 return /*await*/ this.serve_dir(env, pathname, components.slice(1))
687 env.mime_type = 'application/octet-stream'
688 env.caching = this.options.caching
689 let pathname = decodeURIComponent(env.parsed_url.pathname)
690 let i = pathname.lastIndexOf('.')
692 let extension = pathname.slice(i)
694 Object.prototype.hasOwnProperty.call(this.options.mime_types, extension)
696 env.mime_type = this.options.mime_types[extension]
699 await this.serve_zip(
701 this.root + '/_favicon/favicons.zip',
706 let components = pathname.split('/')
707 if (components.length) {
708 assert(components[0].length == 0)
709 components = components.slice(1)
712 // deal with ACME challenges for certbot (letsencrypt)
713 if (components[0] === '.well-known') {
714 // build path, ensuring that remaining components are safe
715 /*let*/ pathname = `${this.options.certbot_webroot}/.well-known`
716 for (let i = 1; i < components.length; ++i) {
717 if (components[i].charAt(0) == '.')
720 `Path "${pathname}" followed by bad component "${components[i]}".`,
723 pathname = `${pathname}/${components[i]}`
726 // use serve_fs() because challenge files have no extension
727 return /*await*/ this.serve_fs(env, pathname)
730 return /*await*/ this.serve_path(env, this.root, components)