Fix let-bug in Resources.unref() caught by ES6 (another was fixed previously)
[jst_server.git] / Site.mjs
1 /*
2  * Copyright (C) 2018-2022 Nick Downing <nick@ndcode.org>
3  * SPDX-License-Identifier: MIT
4  *
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:
11  *
12  * The above copyright notice and this permission notice shall be included in
13  * all copies or substantial portions of the Software.
14  *
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
21  * IN THE SOFTWARE.
22  */
23
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'
39 import fs from 'fs'
40 import fsPromises from 'fs/promises'
41 import jst_server from './index.mjs'
42
43 class Site {
44   constructor(resources, root, options/*, prev_site*/) {
45     this.resources = resources
46     this.root = root
47     this.options = Object.assign(
48       {
49         caching: false,
50         mime_types: {
51           '.css': 'text/css; charset=utf-8',
52           '.html': 'text/html; charset=utf-8',
53           '.ico': 'image/x-icon',
54           '.jpg': 'image/jpeg',
55           '.jpeg': 'image/jpeg',
56           '.js': 'application/javascript; charset=utf-8',
57           '.json': 'application/json; charset=utf-8',
58           '.mp4': 'video/mp4',
59           '.pdf': 'application/pdf',
60           '.png': 'image/png',
61           '.svg': 'image/svg+xml',
62           '.ttf': 'application/octet-stream',
63           '.woff': 'font/woff',
64           '.woff2': 'font/woff2',
65           '.xml': 'text/xml; charset=utf-8'
66         },
67         certbot_webroot: '/var/www/html'
68       },
69       options || {}
70     )
71
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
83
84     this.socket_io_connect_listeners = [] // later will use this for destruction
85   }
86
87   async start() {
88     assert(this.json_cache === undefined)
89     this.json_cache = await this.resources.ref(
90       'json_cache',
91       async () => new JSONCache(true)
92     )
93
94     assert(this.json_cache_rw === undefined)
95     this.json_cache_rw = await this.resources.ref(
96       'json_cache_rw',
97       async () => new JSONCacheRW(true)
98     )
99
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)
104     )
105
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)
110     )
111
112     assert(this.menu_cache === undefined)
113     this.menu_cache = await this.resources.ref(
114       'menu_cache',
115       async () => new MenuCache(true)
116     )
117
118     assert(this.min_css_cache === undefined)
119     this.min_css_cache = await this.resources.ref(
120       'min_css_cache',
121       async () => new MinCSSCache(true)
122     )
123
124     assert(this.min_js_cache === undefined)
125     this.min_js_cache = await this.resources.ref(
126       'min_js_cache',
127       async () => new MinJSCache(true)
128     )
129
130     assert(this.min_html_cache === undefined)
131     this.min_html_cache = await this.resources.ref(
132       'min_html_cache',
133       async () => new MinHTMLCache(true)
134     )
135
136     assert(this.min_svg_cache === undefined)
137     this.min_svg_cache = await this.resources.ref(
138       'min_svg_cache',
139       async () => new MinSVGCache(true)
140     )
141
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)
146     )
147
148     assert(this.text_cache === undefined)
149     this.text_cache = await this.resources.ref(
150       'text_cache',
151       async () => new TextCache(true)
152     )
153
154     assert(this.zip_cache === undefined)
155     this.zip_cache = await this.resources.ref(
156       'zip_cache',
157       async () => new ZipCache(true)
158     )
159   }
160
161   async stop() {
162     assert(this.json_cache !== undefined)
163     await this.resources.unref('json_cache')
164
165     assert(this.json_cache_rw !== undefined)
166     await this.resources.unref('json_cache_rw')
167
168     assert(this.jst_cache !== undefined)
169     await this.resources.unref(`jst_cache:${this.root}`)
170
171     assert(this.less_css_cache !== undefined)
172     await this.resources.unref(`less_css_cache:${this.root}`)
173
174     assert(this.menu_cache !== undefined)
175     await this.resources.unref('menu_cache')
176
177     assert(this.min_css_cache !== undefined)
178     await this.resources.unref('min_css_cache')
179
180     assert(this.min_js_cache !== undefined)
181     await this.resources.unref('min_js_cache')
182
183     assert(this.min_html_cache !== undefined)
184     await this.resources.unref('min_html_cache')
185
186     assert(this.min_svg_cache !== undefined)
187     await this.resources.unref('min_svg_cache')
188
189     assert(this.sass_css_cache !== undefined)
190     await this.resources.unref(`sass_css_cache:${this.root}`)
191
192     assert(this.text_cache !== undefined)
193     await this.resources.unref('text_cache')
194
195     assert(this.zip_cache !== undefined)
196     await this.resources.unref('zip_cache')
197   }
198
199   async kick() {
200     assert(this.json_cache !== undefined)
201     this.json_cache.kick()
202
203     assert(this.json_cache_rw !== undefined)
204     this.json_cache_rw.kick()
205
206     assert(this.jst_cache !== undefined)
207     this.jst_cache.kick()
208
209     assert(this.less_css_cache !== undefined)
210     this.less_css_cache.kick()
211
212     assert(this.menu_cache !== undefined)
213     this.menu_cache.kick()
214
215     assert(this.min_css_cache !== undefined)
216     this.min_css_cache.kick()
217
218     assert(this.min_js_cache !== undefined)
219     this.min_js_cache.kick()
220
221     assert(this.min_html_cache !== undefined)
222     this.min_html_cache.kick()
223
224     assert(this.min_svg_cache !== undefined)
225     this.min_svg_cache.kick()
226
227     assert(this.sass_css_cache !== undefined)
228     this.sass_css_cache.kick()
229
230     assert(this.text_cache !== undefined)
231     this.text_cache.kick()
232
233     assert(this.zip_cache !== undefined)
234     this.zip_cache.kick()
235   }
236
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)
246     response.end(data)
247   }
248
249   serve(env, status, data, from) {
250     console.log(
251       `${env.parsed_url.host} serving ${env.parsed_url.pathname} size ${data.length} from ${from}`
252     )
253     this.serve_internal(env.response, status, env.mime_type, env.caching, data)
254   }
255
256   redirect(env, pathname, message) {
257     console.log(
258       `${env.parsed_url.host} redirecting ${env.parsed_url.pathname} to ${pathname}`
259     )
260     let location = pathname + (env.parsed_url.search || '')
261     env.response.statusCode = 301
262     env.response.setHeader('Location', location)
263     this.serve_internal(
264       env.response,
265       301,
266       this.options.mime_types['.html'],
267       false,
268       Buffer.from(
269         `<html>
270   <head>
271     <meta http-equiv="content-type" content="text/html;charset=utf-8">
272     <title>301 Moved Permanently</title>
273   </head>
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>.
277   </body>
278 </html>
279 `,
280         'utf-8'
281       )
282     )
283   }
284
285   async internal_ensure_dir(pathname) {
286     try {
287       await fsPromises.mkdir(pathname)
288       console.log('create directory', pathname)
289     }
290     catch (err) {
291       if (err.code !== 'EEXIST') // should check error type
292         throw err
293     }
294   }
295
296   get_json(pathname) {
297     return /*await*/ this.json_cache.get(this.root + pathname)
298   }
299
300   get_jst(pathname) {
301     return /*await*/ this.jst_cache.get(this.root + pathname)
302   }
303
304   get_less_css(pathname) {
305     return /*await*/ this.less_css_cache.get(this.root + pathname)
306   }
307
308   get_menu(pathname) {
309     return /*await*/ this.menu_cache.get(this.root + pathname)
310   }
311
312   get_min_css(pathname) {
313     return /*await*/ this.min_css_cache.get(this.root + pathname)
314   }
315
316   get_min_html(pathname) {
317     return /*await*/ this.min_html_cache.get(this.root + pathname)
318   }
319
320   get_min_js(pathname) {
321     return /*await*/ this.min_js_cache.get(this.root + pathname)
322   }
323
324   get_min_svg(pathname) {
325     return /*await*/ this.min_svg_cache.get(this.root + pathname)
326   }
327
328   get_sass_css(pathname) {
329     return /*await*/ this.sass_css_cache.get(this.root + pathname)
330   }
331
332   get_text(pathname) {
333     return /*await*/ this.text_cache.get(this.root + pathname)
334   }
335
336   get_zip(pathname) {
337     return /*await*/ this.zip_cache.get(this.root + pathname)
338   }
339
340   async ensure_dir(pathname) {
341     return /*await*/ this.internal_ensure_dir(this.root + pathname)
342   }
343
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,
349       default_value
350     )
351   }
352
353   async write_json(pathname, value, timeout) {
354     return /*await*/ this.json_cache_rw.write(
355       this.root + pathname,
356       value,
357       timeout
358     )
359   }
360
361   async modify_json(pathname, default_value, modify_func, timeout) {
362     return /*await*/ this.json_cache_rw.modify(
363       this.root + pathname,
364       default_value,
365       modify_func,
366       timeout
367     )
368   }
369
370   async serve_jst(env, pathname, ...args) {
371     let template
372     try {
373       template = await this.jst_cache.get(pathname)
374     }
375     catch (err) {
376       if (!(err instanceof Error) || err.code !== 'ENOENT')
377         throw err
378       return false
379     }
380     env.site = this
381     await template(env, ...args)
382     return true
383   }
384
385   async serve_less_css(env, pathname) {
386     if (pathname.slice(-9) !== '.css.less')
387       return false
388
389     let data
390     try {
391       data = await this.less_css_cache.get(pathname)
392     }
393     catch (err) {
394       if (!(err instanceof Error) || err.code !== 'ENOENT')
395         throw err
396       return false
397     }
398     this.serve(env, 200, data, 'less_css')
399     return true
400   }
401
402   async serve_min_css(env, pathname) {
403     if (pathname.slice(-8) !== '.css.min')
404       return false
405
406     let data
407     try {
408       data = await this.min_css_cache.get(pathname)
409     }
410     catch (err) {
411       if (!(err instanceof Error) || err.code !== 'ENOENT')
412         throw err
413       return false
414     }
415     this.serve(env, 200, data, 'min_css')
416     return true
417   }
418
419   async serve_min_html(env, pathname) {
420     if (pathname.slice(-9) !== '.html.min')
421       return false
422
423     let data
424     try {
425       data = await this.min_html_cache.get(pathname)
426     }
427     catch (err) {
428       if (!(err instanceof Error) || err.code !== 'ENOENT')
429         throw err
430       return false
431     }
432     this.serve(env, 200, data, 'min_html')
433     return true
434   }
435
436   async serve_min_js(env, pathname) {
437     if (pathname.slice(-7) !== '.js.min')
438       return false
439
440     let data
441     try {
442       data = await this.min_js_cache.get(pathname)
443     }
444     catch (err) {
445       if (!(err instanceof Error) || err.code !== 'ENOENT')
446         throw err
447       return false
448     }
449     this.serve(env, 200, data, 'min_js')
450     return true
451   }
452
453   async serve_min_svg(env, pathname) {
454     if (pathname.slice(-8) !== '.svg.min')
455       return false
456
457     let data
458     try {
459       data = await this.min_svg_cache.get(pathname)
460     }
461     catch (err) {
462       if (!(err instanceof Error) || err.code !== 'ENOENT')
463         throw err
464       return false
465     }
466     this.serve(env, 200, data, 'min_svg')
467     return true
468   }
469
470   async serve_sass_css(env, pathname) {
471     if (
472       pathname.slice(-9) !== '.css.sass' &&
473         pathname.slice(-9) !== '.css.scss'
474     )
475       return false
476
477     let data
478     try {
479       data = await this.sass_css_cache.get(pathname)
480     }
481     catch (err) {
482       if (!(err instanceof Error) || err.code !== 'ENOENT')
483         throw err
484       return false
485     }
486     this.serve(env, 200, data, 'sass_css')
487     return true
488   }
489
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)
496
497     // see https://dev.to/abdisalan_js/how-to-code-a-video-streaming-server-using-nodejs-2o0
498     let stream
499     let range = env.request.headers.range;
500     if (range !== undefined) {
501       let stats
502       try {
503         stats = await fsPromises.stat(pathname)
504       }
505       catch (err) {
506         if (!(err instanceof Error) || err.code !== 'ENOENT')
507           throw err
508         return false
509       }
510
511       // Parse Range
512       // Example: "bytes=32324-"
513       let start = Number(range.replace(/\D/g, ''))
514       let end = Math.min(start + 1048576, stats.size)
515
516       // see serve()
517       console.log(
518         `${env.parsed_url.host} streaming ${env.parsed_url.pathname} partial ${start}-${end}/${stats.size}`
519       )
520
521       // Create headers
522       env.response.statusCode = 206 // partial content
523       env.response.setHeader(
524         'Content-Range',
525         `bytes ${start}-${end - 1}/${stats.size}`
526       )
527       env.response.setHeader('Accept-Ranges', 'bytes')
528       env.response.setHeader('Content-Length', end - start)
529
530       // create video read stream for this particular chunk
531       stream = fs.createReadStream(pathname, {start: start, end: end - 1})
532     }
533     else {
534       // see serve()
535       console.log(
536         `${env.parsed_url.host} streaming ${env.parsed_url.pathname}`
537       )
538
539       // see serve_internal()
540       env.response.statusCode = 200
541       stream = fs.createReadStream(pathname)
542     }
543
544     return /*await*/ new Promise(
545       (resolve, reject) => {
546         stream.on(
547           'error',
548           err => {
549             //console.log(`error: ${err.message}`)
550             if (!(err instanceof Error) || err.code !== 'ENOENT')
551               reject(err)
552             resolve(false)
553           }
554         )
555         stream.on(
556           'data',
557           data => {
558             //console.log(`data: ${data.length} bytes`)
559             env.response.write(data)
560           }
561         )
562         stream.on(
563           'end',
564           () => {
565             //console.log('end')
566             env.response.end()
567             resolve(true)
568           }
569         )
570       }
571     )
572   }
573
574   async serve_zip(env, zipname, pathname) {
575     let zip
576     try {
577       zip = await this.zip_cache.get(zipname)
578     }
579     catch (err) {
580       if (!(err instanceof Error) || err.code !== 'ENOENT')
581         throw err
582       return false
583     }
584     if (!Object.prototype.hasOwnProperty.call(zip, pathname))
585       return false
586     this.serve(env, 200, zip[pathname], 'zip')
587     return true
588   }
589
590   async serve_file(env, pathname) {
591     if (
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)
601     )
602       throw new Problem(
603         'Not found',
604         `File "${pathname}" not found.`,
605         404
606       )
607   }
608
609   async serve_dir(env, pathname, components) {
610     if (await this.serve_jst(env, pathname + '.dir.jst', pathname, components))
611       return
612
613     let stats
614     try {
615       stats = await fsPromises.stat(pathname)
616     }
617     catch (error) {
618       if (!(error instanceof Error) || error.code !== 'ENOENT')
619         throw error
620       throw new Problem(
621         'Not found',
622         `Directory "${pathname}" not found.`,
623         404
624       )
625     }
626     if (!stats.isDirectory())
627       throw new Problem(
628         'Not found',
629         `Path "${pathname}" not directory.`,
630         404
631       )
632     return /*await*/ this.serve_path(env, pathname, components)
633   }
634
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')
640       return
641     }
642
643     if (components[0].length === 0) {
644       if (components.length > 1)
645         throw new Problem(
646           'Not found',
647           `Path "${pathname}" followed by empty directory name.`,
648           404
649         )
650       // directory with trailing slash
651       this.redirect(env, env.parsed_url.pathname + 'index.html')
652       return
653     }
654
655     if (
656       components[0].charAt(0) === '.' ||
657       components[0].charAt(0) === '_'
658     )
659       throw new Problem(
660         'Not found',
661         `Path "${pathname}" followed by bad component "${components[0]}".`,
662         404
663       )
664
665     let i = components[0].lastIndexOf('.')
666     if (i === -1)
667       i = components[0].length
668     let extension = components[0].slice(i)
669
670     pathname = `${pathname}/${components[0]}`
671     if (
672       extension.length !== 0 &&
673       Object.prototype.hasOwnProperty.call(this.options.mime_types, extension)
674     ) {
675       if (components.length > 1)
676         throw new Problem(
677           'Not found',
678           `Directory "${pathname}" has non-directory extension "${extension}".`,
679           404
680         )
681       return /*await*/ this.serve_file(env, pathname)
682     }
683     return /*await*/ this.serve_dir(env, pathname, components.slice(1))
684   }
685
686   async respond(env) {
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('.')
691     if (i !== -1) {
692       let extension = pathname.slice(i)
693       if (
694         Object.prototype.hasOwnProperty.call(this.options.mime_types, extension)
695       )
696         env.mime_type = this.options.mime_types[extension]
697     }
698     if (
699       await this.serve_zip(
700         env,
701         this.root + '/_favicon/favicons.zip',
702         pathname
703       )
704     )
705       return
706     let components = pathname.split('/')
707     if (components.length) {
708       assert(components[0].length == 0)
709       components = components.slice(1)
710     }
711
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) == '.')
718           throw new Problem(
719             'Not found',
720             `Path "${pathname}" followed by bad component "${components[i]}".`,
721             404
722           )
723         pathname = `${pathname}/${components[i]}`
724       }
725
726       // use serve_fs() because challenge files have no extension
727       return /*await*/ this.serve_fs(env, pathname)
728     }
729
730     return /*await*/ this.serve_path(env, this.root, components)
731   }
732 }
733
734 export default Site