Break the main functionality into analytics, config, resources and site modules
[jst_server.git] / site.js
1 let analytics = require('./analytics')
2 let assert = require('assert')
3 let cookie = require('cookie')
4 let crypto = require('crypto')
5 let fs = require('fs')
6 let config = require('./config')
7 let resources = require('./resources')
8 let util = require('util')
9 let url = require('url')
10 let XDate = require('xdate')
11
12 let fs_readFile = util.promisify(fs.readFile)
13 let fs_stat = util.promisify(fs.stat)
14
15 let serve = (res, status, mime_type, data) => {
16   res.statusCode = status
17   // html files will be direct recipient of links/bookmarks so can't have
18   // a long lifetime, other files like css or images are often large files
19   // and won't change frequently (but we'll need cache busting eventually)
20   if (
21     false && //commander.enableCaching &&
22     mime_type !== config.mime_types_html
23   )
24     res.setHeader('Cache-Control', 'max-age=3600')
25   res.setHeader('Content-Type', mime_type)
26   res.setHeader('Content-Length', data.length)
27   res.end(data)
28 }
29
30 let die = res => {
31   let body = '<html><body>Page not found</body></html>'
32   serve(res, 404, config.mime_type_html, new Buffer(body, 'utf8'))
33 }
34
35 let redirect = (res, location) => {
36   res.statusCode = 301
37   res.setHeader('Location', location)
38   res.end('Redirecting to ' + location)
39 }
40
41 let app = async (req, res, protocol) => {
42   let site = req.headers.host || 'localhost'
43   let temp = site.indexOf(':')
44   let port_suffix = temp === -1 ? '' : site.substring(temp)
45   site = site.substring(0, site.length - port_suffix.length)
46   if (!config.sites.hasOwnProperty(site)) {
47     console.log('nonexistent site', site)
48     die(res)
49     return
50   }
51   temp = config.sites[site]
52   let site_root
53   if (temp.type === 'redirect') {
54     let site_domain = temp.domain
55     console.log('redirecting', site, 'to', site_domain)
56     redirect(res, protocol + '://' + site_domain + port_suffix + req.url)
57     return
58   }
59   else if (temp.type === 'site')
60     site_root = temp.root
61   else
62     assert(false)
63
64   // parse the pathname portion of url
65   // this is actually cheating since it's not a complete url
66   let parsed_url = url.parse(req.url, true)
67   let path = parsed_url.pathname.split('/')
68
69   // path must begin with /
70   if (path.length === 0 || path[0].length)
71     return die(res)
72
73   // path elements must be findable in the file system (thus can't be empty)
74   let dir_name = ''
75   let dir_name_is_pub = false
76   for (let i = 1; i < path.length - 1; ++i) {
77     dir_name += '/' + path[i]
78     if (path[i].length === 0 || path[i].charAt(0) === '.') {
79       console.log(site, 'bad path component', dir_name)
80       return die(res)
81     }
82     let stats
83     try {
84       stats = await fs_stat(site_root + dir_name)
85     }
86     catch (err) {
87       if (err.code !== 'ENOENT')
88         throw err
89       if (!dir_name_is_pub) {
90         temp = dir_name + '.pub'
91         try {
92           stats = await fs_stat(site_root + temp)
93           dir_name = temp
94           dir_name_is_pub = true
95         }
96         catch (err2) {
97           if (err2.code !== 'ENOENT')
98             throw err2
99           console.log(site, 'directory not found', dir_name)
100           return die(res)
101         }
102       }
103       if (!stats.isDirectory()) {
104         console.log(site, 'not directory', dir_name)
105         return die(res)
106       }
107     }
108   }
109
110   file_name = path[path.length - 1]
111   if (file_name === '') {
112     path[path.length - 1] = 'index.html'
113     path = path.join('/')
114     console.log(site, 'redirecting', parsed_url.pathname, 'to', path)
115     redirect(res, path + (parsed_url.search || ''))
116     return
117   }
118   let page = path.slice(1).join('/')
119
120   temp = file_name.lastIndexOf('.')
121   let file_type = temp === -1 ? '' : file_name.substring(temp + 1)
122   let mime_type =
123     config.mime_types.hasOwnProperty(file_type) ?
124     config.mime_types[file_type] :
125     config.mime_type_default
126
127   if (file_type == 'html') {
128     if (!analytics.sessions.hasOwnProperty(site))
129       analytics.sessions[site] = {}
130     let site_sessions = analytics.sessions[site]
131     let cookies = cookie.parse(req.headers.cookie || ''), session_key
132     if (
133       !cookies.hasOwnProperty('session_key') ||
134       !site_sessions.hasOwnProperty(session_key = cookies.session_key)
135     ) {
136       session_key = crypto.randomBytes(16).toString('hex')
137       site_sessions[session_key] = {}
138     }
139     let session = site_sessions[session_key]
140   
141     let expires = new XDate()
142     expires.addMonths(1)
143     expires = expires.toUTCString()
144     res.setHeader(
145       'Set-Cookie',
146       'session_key=' + session_key + '; expires=' + expires + '; path=/;'
147     )
148     session.expires = expires
149
150     if (!analytics.pageviews.hasOwnProperty(site))
151       analytics.pageviews[site] = {}
152     let site_pageviews = analytics.pageviews[site]
153     if (!site_pageviews.hasOwnProperty(page))
154       site_pageviews[page] = {visits: 0, unique_visits: 0}
155     let pageview = site_pageviews[page]
156     ++pageview.visits;
157
158   
159     if (!session.hasOwnProperty('analytics.pageviews'))
160       session.pageviews = {}
161     let session_pageviews = session.pageviews
162     if (!session_pageviews.hasOwnProperty(page)) {
163       session_pageviews[page] = 0
164       ++pageview.unique_visits
165     }
166     ++session_pageviews[page]
167
168     analytics.sessions_dirty()
169     analytics.pageviews_dirty()
170   }
171
172   /*let*/ page = dir_name + '/' + file_name; let data
173   if (dir_name_is_pub) {
174     try {
175       let data = await fs_readFile(site_root + page)
176       console.log(
177         site,
178         'serving',
179         page,
180         'length',
181         data.length,
182         'from pub'
183       )
184       serve(res, 200, mime_type, data)
185       return
186     }
187     catch (err) {
188       if (err.code !== 'ENOENT')
189         throw err
190     }
191   }
192   else {
193     temp = page + '.pub'
194     try {
195       let data = await fs_readFile(site_root + temp)
196       console.log(
197         site,
198         'serving',
199         temp,
200         'length',
201         data.length,
202         'from pub'
203       )
204       serve(res, 200, mime_type, data)
205       return
206     }
207     catch (err) {
208       if (err.code !== 'ENOENT')
209         throw err
210     }
211
212     switch (file_type) {
213     case 'html':
214       temp = page + '.js'
215       try {
216         let buffers = []
217         let env = {
218           lang: 'en',
219           page: page,
220           query: parsed_url.query,
221           site: site,
222           site_root: site_root
223         }
224         let out = str => {buffers.push(Buffer.from(str))}
225         let req = async (str, type) => {
226           let path = (
227             str.length > 0 && str.charAt(0) === '/' ?
228             site_root :
229             site_root + dir_name + '/'
230           ) + str, result
231           switch (type) {
232           case undefined:
233             result = await (await resources.req_js(path))(env, out, req)
234             break
235           case 'js':
236             result = await resources.req_js(path)
237             break
238           case 'json':
239             result = await resources.req_json(path)
240             break
241           case 'text':
242             result = await resources.req_text(path)
243             break
244           case 'zet':
245             result = await resources.req_zet(path)
246             break
247           default:
248             assert(false) 
249           }
250           return result
251         }
252         await req(temp)
253         let data = Buffer.concat(buffers)
254         console.log(
255           site,
256           'serving',
257           temp,
258           'length',
259           data.length,
260           'from js'
261         )
262         serve(res, 200, mime_type, data)
263         return
264       }
265       catch (err) {
266         if (err.code !== 'ENOENT') // should check error type
267           throw err
268       }
269       break
270
271     case 'css':
272       temp = page + '.less'
273       try {
274         let data = await resources.req_less(site_root + temp, site_root, dir_name)
275         console.log(
276           site,
277           'serving',
278           temp,
279           'length',
280           data.length,
281           'from less'
282         )
283         serve(res, 200, mime_type, data)
284         return
285       }
286       catch (err) {
287         if (err.code !== 'ENOENT') // note: err.code might be undefined
288           throw err
289       }
290       break
291     }
292   }
293
294   let favicons = await resources.req_zip(site_root + '/favicons.zip')
295   temp = page.substring(1) // fix this to avoid leading / on all absolute paths
296   if (favicons.hasOwnProperty(temp)) {
297     let data = favicons[temp]
298     console.log(
299       site,
300       'serving',
301       page,
302       'length',
303       data.length,
304       'from favicons'
305     )
306     serve(res, 200, mime_type, data)
307     return
308   }
309
310   console.log(site, 'file not found', page)
311   return die(res)
312 }
313
314 let tryApp = async (req, res, protocol) => {
315   await config.refresh()
316   try {
317     await app(req, res, protocol)
318   }
319   catch (err) {
320     let message = (err.stack || err.message).toString()
321     console.error(message)
322     let body = '<html><body><pre>' + message + '</pre></body></html>'
323     serve(res, 500, config.mime_type_html, new Buffer(body, 'utf8'))
324   }
325 }
326
327 exports.serve = serve
328 exports.die = die
329 exports.redirect = redirect
330 exports.app = app
331 exports.tryApp = tryApp