Server-class is functional
[gopher-lib.git] / server.js
1 /*jslint node: true */
2 /*jshint esversion: 6 */
3
4 'use strict';
5
6 const net = require('net');
7 const fs = require('fs');
8 const path = require('path');
9
10 const Common = require('./common.js');
11 const GopherResource = Common.Resource;
12
13
14 /** @class */
15 class Reply {
16         constructor(hostname, port, socket, endCallback) {
17                 /**
18                  * @property {net.Socket} socket - The node network socket, contains interesting information. (Voids warranty)
19                  */
20                 this.socket = socket;
21
22                 this.hostname=hostname;
23                 this.port=port;
24
25                 this.queue = 0;
26                 this.endCallback = endCallback;
27                 this.wroteMenu = false;
28         }
29
30         /**
31          * @param {string} - data
32          * @description Send data to client. Connection kept open!
33          */
34         write(data) {
35                 if (!this.socket.write(data)) {
36                         this.queue++;
37                         var self = this;
38                         this.socket.on('drain', () => {
39                                 self.queue--;
40                         });
41                 }
42         }
43
44         /**
45          * @param {string} filname
46          * @description Send file from disk, close connection when complete.
47          */
48         file(fn) {
49                 var socket = this.socket;
50
51                 var self = this;
52                 var readStream = fs.createReadStream(fn);
53
54                 socket.on('drain', () => {
55                         readStream.resume();
56                 });
57
58                 readStream.on('data', (data) => {
59                         // Todo: detect start-of-line, check if first character is '.', prepend another '.' if so.
60                         if (!socket.write(data)) {
61                                 readStream.pause();
62                         }
63                 });
64
65                 readStream.on('end', () => {
66                         if (socket.bufferSize) {
67                                 socket.on('drain', () => {
68                                         self.end();
69                                 });
70                         } else {
71                                 self.end();
72                         }
73                 });
74         }
75
76         /**
77          * @param {GopherMapEntry} item
78          * @description Send one Gopher menu-entry to the client. Connection kept open!
79          */
80         menuItem(item) {
81                 this.wroteMenu = true;
82                 if (item.url) {
83                         item.host = item.url;
84                 } else if (item.type != 'i' && item.type != '3') {
85                         item.host = item.host || this.hostname;
86                         item.port = item.port || this.port;
87                 } else {
88                         item.host = 'h';
89                         item.port = '1';
90                 }
91                 item.selector = item.selector || '';
92                 this.write(new GopherResource(item.host, item.port, item.selector, item.type, item.name).toDirectoryEntity());
93         }
94
95         /**
96          * @param {string} message
97          * @description Send an error item to client. Connection kept open!
98          */
99         menuErr(txt) {
100                 this.menuItem(GopherResource.error(txt));
101         }
102
103         /**
104          * @param {string} message
105          * @description Send an info item to client. Connection kept open!
106          */
107         menuInfo(txt) {
108                 this.menuItem(GopherResource.info(txt));
109         }
110
111         /**
112          * @description Close connection. Note: This waits for data to be sent to client before the connection is closed.
113          */
114         end() {
115                 var socket = this.socket;
116                 var self = this;
117
118                 if (this.wroteMenu) {
119                         socket.write('.');
120                 }
121
122                 if (this.queue || socket.bufferSize) {
123                         socket.on('drain', () => {
124                                 socket.end();
125                                 self.endCallback();
126                         });
127                 } else {
128                         socket.end();
129                         self.endCallback();
130                 }
131         }
132 }
133
134
135 /** @class */
136 class GopherMenu {
137         constructor() {
138                 this.entries = [];
139         }
140
141         send(reply) {
142                 for (var entry of this.entries) {
143                         reply.menuItem(entry);
144                 }
145                 reply.end();
146         }
147
148         /**
149          * @param {fileName} fileName - Name of file containing JSON array of GopherMapEntry
150          * @param {bool} [silent=false] - Don't throw error if adding the file fails
151          * @description Populate the menu from a file, see {@link GopherMapEntry}
152          * 
153          */
154         fromFile(fileName, silent) {
155
156                 try {
157                         var fileObj = JSON.parse(fs.readFileSync(fileName));
158                         if (fileObj) {
159                                 fileObj.forEach((itemObj) => {
160                                         this.addEntry(itemObj);
161                                 });
162                         }
163                 } catch (e) {
164                         if (!silent) {
165                                 throw e;
166                         }
167                 }
168         }
169
170         /**
171          * @param {GopherMapEntry} item - Item to add to menu
172          * @description Add a menu-item to the menu, fills out host, port if empty.
173          * @returns {GopherMenu} - Returns same instance for easy chaining.
174          */
175         addEntry(item) {
176                 /**
177                  * @typedef {Object} GopherMapEntry
178                  * @description An object from which a GopherResource can be constructed
179                  * @property {string} [type='3'] - The type of resource that this is
180                  * @property {string} [url] - A gopher url, all other parameters are ignored.
181                  * @property {string} [name='No Name] - Name of this entry
182                  * @property {string} [selector=''] - Selector
183                  * @property {string} [host=(Servers hostname)] - If empty, autofilled if type != 'i' or '3'
184                  * @property {string} [port=(Servers port)] - If empty, autofilled if type != 'i' or '3'
185                  * 
186                  */
187
188                 this.entries.push(item);
189                 return this;
190         }
191
192 }
193
194
195 /** @class */
196 class GopherServer {
197
198         /**
199          * @param {integer} port - The port on which to listen
200          * @param {string} hostname - The hostname that should be reported in menus
201          * @description Create a new GopherServer class, it will not listen before you call listen().
202          * Note that the server supports listening on a different port than it reports to clients.
203          * */
204         constructor(port, hostname) {
205                 this.handlers = new Map();
206                 this.handlers.set('err', this._errHandler);
207                 this.server = null;
208                 this.port = port;
209                 this.hostname = hostname;
210                 this.preHandlers = [];
211                 this.postHandlers = [];
212                 this.numRequests = 1;
213         }
214
215         _errHandler(request, reply) {
216                 reply.menuErr('Selector not found: "' + request.selector + '"');
217                 reply.end();
218         }
219
220         /**
221          * @param {GopherServer~handlerCallback} preHandler
222          * @description Add a handler that is called on any selector (found or not), this should generally not send data or modify the socket.
223          */
224         addPreHandler(handler) {
225                 this.preHandlers.push(handler);
226         }
227
228         /**
229          * @param {GopherServer~handlerCallback} postHandler
230          * @description Add a handler that is called on any selecter, after the reply.end() has been called, and socket been drained and closed.
231          */
232         addPostHandler(handler) {
233                 this.postHandlers.push(handler);
234         }
235
236
237         /**
238          * @param {string} selector - Selector that this handler should serve
239          * @param {GopherServer~handlerCallback} - The handler to serve this selector
240          */
241         addHandler(selector, handler) {
242                 this.handlers.set(selector, handler);
243         }
244
245         /**
246          * @param {string} selector - The selector on which file should be available
247          * @param {string} filename - The file to send
248          * @description Send a file when selector is hit, this could be anything, even a gophermap
249          */
250         addFile(selector, fn) {
251                 this.addHandler(selector, (request, reply) => {
252                         reply.file(fn);
253                 });
254         }
255
256         /**
257          * @param {string} - Selector
258          * @description Send a menu from selector
259          * @returns {GopherMenu} - The menu object that was created
260          */
261         addMenu(selector) {
262
263                 var menu = new GopherMenu();
264
265                 this.addHandler(selector, (request, reply) => {
266                         menu.send(reply);
267                 });
268                 return (menu);
269         }
270
271         fileInfo(fn) {
272                 var info = {
273                         type: '9',
274                         stat: fs.statSync(fn)
275                 };
276
277                 if (info.stat.isDirectory()) {
278                         info.type = '1';
279                 } else {
280                         var ext = path.extname(fn).toLowerCase();
281                         switch (ext) {
282                                 case '.html':
283                                 case '.htm':
284                                 case '.txt':
285                                 case '.md':
286                                 case '.c':
287                                 case '.cpp':
288                                 case '.h':
289                                 case '.hpp':
290                                 case '.sh':
291                                 case '.js':
292                                 case '.json':
293                                         info.type = '0';
294                                         break;
295                                 case '.gif':
296                                         info.type = 'g';
297                                         break;
298                                 case '.jpg':
299                                 case '.jpe':
300                                 case '.jpeg':
301                                 case '.png':
302                                 case '.tga':
303                                 case '.bmp':
304                                 case '.ico':
305                                         info.type = 'I';
306                                         break;
307                         }
308                 }
309
310                 return (info);
311         }
312
313         /**
314          * @param {string} selector - The selector on which directory listing/menu should be available
315          * @param {string} dir - The directory to scan
316          * @param {GopherServer~addDirOptions} [options] - Options
317          * @description Attach directory to selector
318          */
319         addDir(selector, dir, options) {
320                 /**
321                  * @typedef {object} GopherServer~addDirOptions
322                  * @property {bool} [recurse=true] - Recurse into subdirectories
323                  * @property {bool} [useMap=true] - Read map files and use for menus instead of generating an index
324                  * @property {integer} [showSizeAt=32768] - Filesize at which size is displayed next to the name
325                  * @property {bool} [dotFiles=false] - Include dotFiles in the directory listing
326                  * @property {string} [oldMapFileName='.cache'] - When useMap is enabled, look for this file (if no jsonmap found)
327                  * @property {string} [jsonMapFileName='gophermap.json'] - When useMap is enabled, look for this file before falling back on oldMapFileName or directory index.
328                  */
329
330                 const confDefaults = {
331                         recurse: true,
332                         useMap: true,
333                         showSizeAt: 32768,
334                         dotFiles: false,
335                         oldMapFileName: '.cache',
336                         jsonMapFileName: 'gophermap.json'
337                 };
338
339                 options = options || {};
340
341                 for (var key in confDefaults) {
342                         options[key] = options[key] || confDefaults[key];
343                 }
344
345                 var menu = false;
346
347                 var self = this;
348                 var files = fs.readdirSync(dir);
349
350                 var hasMap = false;
351
352                 if (options.useMap) {
353                         hasMap = files.includes(options.jsonMapFileName);
354                         if (hasMap) {
355                                 menu = this.addMenu(selector);
356                                 menu.fromFile(path.join(dir, options.jsonMapFileName));
357                         } else {
358                                 hasMap = files.includes(options.oldMapFileName);
359                                 if (hasMap) {
360                                         this.addFile(selector, path.join(dir, options.oldMapFileName));
361                                         menu = true;
362                                 }
363                         }
364                 }
365
366                 if (!menu) {
367                         menu = this.addMenu(selector);
368                 }
369
370                 files.forEach((item) => {
371
372                         if (!options.dotFiles && item[0] === '.') {
373                                 return;
374                         }
375
376                         var selectorName = selector + '/' + item;
377                         var longName = path.join(dir, item);
378                         var info = self.fileInfo(longName);
379
380                         if (info.stat.isDirectory()) {
381                                 if (options.recurse) {
382                                         self.addDir(selectorName, longName, options);
383                                 }
384                         } else if (info.stat.isFile()) {
385
386                                 // Add selector for file
387                                 self.addFile(selectorName, longName);
388                                 // Add menu entry for file
389                                 if (info.stat.size >= options.showSizeAt) {
390                                         var size = parseInt(info.stat.size);
391                                         if (size > 1024 * 1024 * 1024) {
392                                                 size /= 1024 * 1024 * 1024;
393                                                 size = size.toFixed(0) + ' GiB';
394                                         } else if (size > 1024 * 1024) {
395                                                 size /= 1024 * 1024;
396                                                 size = size.toFixed(0) + ' MiB';
397                                         } else if (size > 1024) {
398                                                 size /= 1024;
399                                                 size = size.toFixed(0) + ' KiB';
400                                         } else {
401                                                 size = size + ' B';
402                                         }
403                                         item = item + ' (' + size + ')';
404                                 }
405                         }
406
407                         var entry = {
408                                 name: item,
409                                 selector: selectorName,
410                                 type: info.type
411                         };
412
413                         if (!hasMap) {
414                                 if(info.stat.isFile() || options.recurse) {
415                                         menu.addEntry(entry);
416                                 }
417                         }
418
419                 });
420         }
421
422         /**
423          * @description Start listening for incoming connections
424          * @param {function} [callback] - Called when the server is started and listening
425          */
426         listen(callback) {
427                 var self = this;
428
429                 this.server = net.createServer((socket) => {
430                         socket.setEncoding('ascii');
431
432                         var serial = self.numRequests++;
433
434                         socket.on('data', (data) => {
435                                 var args = data.toString().trim().split('\t');
436
437                                 /**
438                                  * @typedef {object} GopherServer~requestInformation
439                                  * @property {string} selector - The selector sent by the client
440                                  * @property {string} [query] - The search string, if any
441                                  * @property {integer} serial - Which request was this (unique during server-lifetime only)
442                                  * @property {GopherServer~handlerCallback} [handler] - Which handler (if any) handles this
443                                  */
444                                 var request = {
445                                         selector: args[0],
446                                         query: args[1],
447                                         serial: serial,
448                                         handler: false
449                                 };
450
451                                 var reply = new Reply(self.hostname, self.port, socket, () => {
452                                         for (var postHandler of self.postHandlers) {
453                                                 postHandler(request, reply);
454                                         }
455                                 });
456
457                                 var handler = self.handlers.get(request.selector);
458                                 if(handler) {
459                                         request.handler = handler;
460                                 }
461
462                                 for (var preHandler of self.preHandlers) {
463                                         preHandler(request, reply);
464                                 }
465
466                                 if (handler) {
467                                         handler(request, reply);
468                                 } else {
469                                         this.handlers.get('err')(request, reply);
470                                 }
471                         });
472
473                         socket.on('error', (e) => {
474                                 console.log('Socket error (request serial ' + serial + '), Error:', e.message);
475                         });
476
477                 });
478
479                 this.server.on('error', (e) => {
480                         console.log('Server error:', e);
481                 });
482
483                 this.server.listen(this.port, () => {
484                         if (callback) {
485                                 callback();
486                         }
487                 });
488         }
489 }
490
491 /**
492  * @callback GopherServer~handlerCallback
493  * @param {GopherServer~requestInformation} request - Information about the request made by the client
494  * @param {Reply} reply - Used to send data back to the client
495  * @description This method is responsible for replying to incoming requests.
496  */
497
498 module.exports = GopherServer;