-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathapp.js
730 lines (626 loc) · 19.8 KB
/
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
/**
* @author Felix Milea-Ciobanu @felix_mc <[email protected]>
* @version 1.0
*
* @see <a href="https://github.com/felixmc/shortnr">GitHub Repository</a>
* @see <a href="http://fmc.io/">Example</a>
*/
// ------------------------------
// setup and config
// ------------------------------
// load config file first (@see config.js)
var config = require( "./config" );
// load database module which contains all the database-related methods for the service (@see database.js)
var database = require( "./database" );
// load modules
var http = require( "http" ),
fs = require( "fs" );
express = require( "express" ),
app = express(),
params = require( "express-params" ),
log = require( "custom-logger" ).config({ level: config.CONSOLE_LOG_LEVEL });
// listen on specified port
app.listen( config.PORT );
// load params module as middleware for express. It makes handling custom parameters a lot easier
params.extend( app );
// define urlCode as a URL segment to watch for
app.param( "urlCode", config.CODE_PATTERN );
// create a database client from the config
// notice that the "custom-logger" module is passed as well so that DB functions can log to the console
database.connect( config.database, log );
// setup variables to store actual lists
var whitelist, blacklist;
// check to see if the whitelist is enabled (@see config.js)
if ( config.WHITELIST )
{
fs.readFile( config.WHITELIST, "utf8", function( error, data )
{
if ( error )
{
log.error( error );
}
else
{
whitelist = data.split( "\n" );
}
});
}
// check to see if the blacklist is enabled (@see config.js)
if ( config.BLACKLIST )
{
fs.readFile( config.BLACKLIST, "utf8", function( error, data )
{
if ( error )
{
log.error( error );
}
else
{
blacklist = data.split( "\n" );
}
});
}
/**
* Used in middleware to filter connections by the whitelist.
*
* @param req Express.js request object
* @param res Express.js response object
* @param next Express.js next function
* @param passCallback function to be executed if the client is found on the whitelist
* @param failCallback function to be executed if the client is not found on the whitelist
*/
var whitelistClient = function( req, res, next, passCallback, failCallback )
{
if ( whitelist.indexOf( req.real_ip ) != -1 )
{
if ( passCallback )
{
passCallback( req, res, next );
}
else
{
next();
}
}
else
{
if ( failCallback )
{
failCallback( req, res, next );
}
else
{
show_403();
}
}
}
/**
* Used in middleware to filter connections by the whitelist.
*
* @param req Express.js request object
* @param res Express.js response object
* @param next Express.js next function
* @param passCallback function to be executed if the client is found on the blacklist
* @param failCallback function to be executed if the client is not found on the blacklist
*/
var blacklistClient = function( req, res, next, passCallback, failCallback )
{
if ( blacklist.indexOf( req.real_ip ) != -1 )
{
if ( passCallback )
{
passCallback( req, res, next );
}
else
{
show_403();
}
}
else
{
if( failCallback )
{
failCallback( req, res, next );
}
else
{
next();
}
}
}
/**
* Return 403 error if the client is denied access to the API or service.
*
* @param req Express.js request object
* @param res Express.js response object
*/
var show_403 = function( req, res )
{
var location = "the API";
if ( config.LIST_SCOPE == 3 )
{
location = "this service";
}
// send error to client
res.send( 403, "Error 403: You do not have permission to query " + location + "." );
res.end();
// log error to console
log.warn( "Client " + req.real_ip + " tried to access " + location + " and was denied." );
}
/**
* Middleware for parsing the real IP address from the client.
*
* @param req Express.js request object
* @param res Express.js response object
* @param next Express.js next function
*/
app.use( function( req, res, next )
{
/**
* Set real_ip property to real IP of client if using a reverse proxy (otherwise it will return 127.0.0.1)
* If not using proxy, it should fall back to req.ip
*/
req.real_ip = req.header("x-real-ip") || req.ip;
next();
});
/**
* Middleware for filtering clients based on the whitelist and blacklist.
*
* @param req Express.js request object
* @param res Express.js response object
* @param next Express.js next function
*/
app.use( function( req, res, next )
{
// check to see if the filtering has a scope (@see config.js)
if ( config.LIST_SCOPE > 0 )
{
// check that the current request path is affected by the white/black listing (@see config.js)
var path = ( config.LIST_SCOPE < 3 && req.path.indexOf("/api") == 0 ) || ( config.LIST_SCOPE == 2 && req.path.indexOf("/stats") == 0 ) || ( config.LIST_SCOPE == 3 );
// check that the current request method is affected by the white/black listing (@see config.js)
var method = ( config.LIST_SCOPE == 1 && req.method == "POST" ) || ( config.LIST_SCOPE == 2 && ( req.method == "POST" || req.method == "GET" ) ) || ( config.LIST_SCOPE == 3 );
if ( path && method )
{
// if whitelist and blacklist are both enabled
if ( config.WHITELIST != undefined && config.BLACKLIST != undefined )
{
// if whitelist "gets the last word" (@see config.js)
if ( config.WHITELIST_LAST )
{
/**
* Below the function to filter clients based on the whitelist is called
* First three parameters are standard
*
* 4th parameter ( passCallback ) is set to undefined so that the whitelist-filtering function
* allows the client to continue on with their request if they are on the whitelist
*
* 5th parameter ( failCallback ) is set to the blacklist-filtering function
* this means that if the client is not on the whitelist, it will be checked against the blacklist
* before being allowed to proceed with their request
*
* This means that the client will always fulfill their request IFF they are on the whitelist,
* regardless if they are also on the blacklist or not
* OR IFF they are NOT on the blacklist
*/
whitelistClient( req, res, next, undefined, blacklistClient );
}
else
{
/**
* Below the function to filter clients based on the blacklist is called
* First three parameters are standard
*
* 4th parameter ( passCallback ) is set to the show_403 function
* so that clients who are matched on the blacklist cannot fulfill their requests
*
* 5th parameter ( failCallback ) is set to the whitelist-filtering function
* this means that if the client is not on the blacklist, it will be checked against the whitelist
* before being allowed to proceed with their request
*
* This means that the client will only fulfill their request IFF they are NOT on the blacklist,
* BUT they are on the whitelist
*/
blacklistClient( req, res, next, show_403, whitelistClient );
}
}
// if only whitelist is enabled
else if ( config.WHITELIST != undefined )
{
/**
* The whitelist-filtering function below will only allow requests from clients on the list
* and will block everyone else's requests
*/
whitelistClient( req, res, next );
}
// if only blacklist is enabled
else if ( config.BLACKLIST )
{
/**
* The blacklist-filtering function below will block all requests from clients on the list
* and will allow everyone else to fulfill their request
*/
blacklistClient( req, res, next );
}
else
{
next();
}
}
else
{
next();
}
}
else
{
next();
}
});
/**
* Middleware for parsing the request body as JSON.
*
* @param req Express.js request object
* @param res Express.js response object
* @param next Express.js next function
*/
app.use( function( req, res, next )
{
req.setEncoding( "utf8" );
/**
* create a new property to store the actual request body
* the "body" property will be overwritten with the parsed version of the request body
*/
req.rawBody = "";
// listen for data chunks and construct the raw body
req.on( "data", function( chunk )
{
req.rawBody += chunk;
});
// once the client finishes sending data..
req.on( "end", function()
{
// try to parse request body as JSON
try
{
req.body = JSON.parse( req.rawBody );
}
// if it fails, just set it to blank object
catch (e)
{
req.body = {};
}
next();
});
});
// ------------------------------
// setup helper functions
// ------------------------------
/**
* Generates a new random URL code.
*
* @return code A randomly-generated URL code based on the config.
*/
function generateCode()
{
var length = config.CODE_LENGTH,
code = "";
while( length != 0 )
{
code += config.CODE_CHARACTERS.charAt( Math.floor( Math.random() * config.CODE_CHARACTERS.length ) );
length--;
}
return code;
}
/**
* Generates a new random URL code, that is also unique in the database.
*
* @param callback A function to be executed once a new unique URL code is generated.
* This function will take the new URL code as a parameter.
*
* @return code A unique randomly-generated URL code based on the config.
*/
function uniqueCode( callback )
{
/**
* Takes in a URL code and checks to see if it's already being used.
* If the URL code is already in use, it generates a new one and calls itself until a unique URL is found.
*
* @param code A valid URL code to be searched for in the database.
*/
var tryURL = function( code )
{
database.URLFromCode( code, function( url )
{
if ( url == undefined )
{
callback( code );
}
else
{
tryURL( generateCode() );
}
});
}
tryURL( generateCode() );
}
/**
* Checks to see if the given URL is long enough to be shortened.
*
* @param url The URL to be analyzed.
*/
function isLongEnough( url )
{
return url.length > ( config.BASE_URL.length + config.CODE_LENGTH);
}
// ------------------------------
// setup the web server/REST service
// ------------------------------
// first setup the home page aka static part of the site
if ( config.SHOW_STATIC_PAGE )
{
app.get( "/", function( req, res )
{
var output = "";
// make a GET request to static page
http.get( config.STATIC_LOCATION, function( response )
{
response.setEncoding( "utf8" );
// load the page one chunk at a time..
response.on( "data", function( chunk )
{
output += chunk;
});
// once the page is finished loaded, return contents of static page to client
response.on( "end", function()
{
res.send( output );
});
});
});
}
/**
* Check to see if the stats functionality is enabled.
*/
if ( config.ENABLE_STATS )
{
/**
* Returns stats about the service in JSON format.
*/
app.get( "/stats/", function( req, res )
{
database.getURLCount( function( urls )
{
database.getTotalVisits( function( count )
{
res.json( 200, { urls: urls, visits: count } );
res.end();
log.info( "GET request to \"" + req.path + "\" from client: " + req.real_ip );
});
});
});
/**
* Returns stats about a specified URL in JSON format.
*/
app.get( "/stats/:urlCode", function( req, res )
{
database.getURLDate( req.params.urlCode[0], function( date )
{
if ( date == undefined )
{
res.send( 404, "Error 404: There is no URL associated with this code." );
res.end();
log.info( "GET request to \"" + req.path + "\" from client: " + req.real_ip );
}
else
{
database.getURLVisits( req.params.urlCode[0], function( count )
{
res.json( 200, { created: date, visits: count } );
res.end();
log.info( "GET request to \"" + req.path + "\" from client: " + req.real_ip );
});
}
});
});
}
/**
* Redirects the client when they try to access a shortened URL.
*/
app.get( "/:urlCode", function( req, res )
{
// query the URL code in the database (@see database.js)
database.URLFromCode( req.params.urlCode[0], function( url )
{
// if URL was found in the database..
if( url != undefined )
{
var status = 301;
// redirect to matching URL and auto-expire the permanent redirect so that it can be logged again in the future
res.writeHead( status, { "Location": url, "Expires": (new Date).toGMTString() } );
res.end();
// log visitor data into database
database.logVisit( req.params.urlCode[0], status, req );
// log request to the console
log.info( "GET request to \"" + req.path + "\" - 301: Successfully redirected user to URL \"" + url + "\"" );
}
// else if there's no matching URL
else
{
var status = 404;
var message = "Error 404: This URL does not redirect to anything.";
// send error message to browser
res.send( status, message );
// log visitor data into database
database.logVisit( req.params.urlCode[0], status, req );
// log request to the console
log.warn( "GET request to \"" + req.path + "\" - " + message );
}
});
});
/**
* Handles API requests to "translate" a URL code into the actual URL.
*/
app.get( "/api/:urlCode", function( req, res )
{
// attempt to retrieve URL from database based on code (@see database.js)
database.URLFromCode( req.params.urlCode[0], function( url )
{
var status = 0;
// if URL was found in the database..
if ( url != undefined )
{
status = 200;
// send long URL to client
res.send( status, url );
// log request to the console
log.info( "GET request to \"" + req.path + "\" - 200: Successfully returned the URL \"" + url + "\"" );
}
// else if there's no matching URL
else
{
status = 404;
var message = "Error 404: The URL code \"" + req.params.urlCode[0] + "\" does not match any URL in the database.";
// send error message to client
res.send( status, message );
// log request to the console
log.warn( "GET request to \"" + req.path + "\" - " + message );
}
// log request to database
database.logTranslate( req.params.urlCode[0], status, req.real_ip );
});
});
/**
* Handles API requests to shorten a URL.
*/
app.post( "/api/?", function( req, res )
{
// retrieve client history from the database (@see database.js)
database.clientHistory( req.real_ip, config.STRICT_LIMITS, function( history )
{
var status = 0;
// check individual time-frame limits
if ( config.limits.MINUTE != 0 && history.minute >= config.limits.MINUTE )
{
status = 429;
res.send( status, "Error 429: Your IP address has reached or exceeded its API requests limit of " + config.limits.MINUTE + " requests per minute." );
log.warn( "Client " + req.ip + " has reached its API requests limit of " + config.limits.MINUTE + " requests per minute." );
}
else if ( config.limits.HOUR != 0 && history.hour >= config.limits.HOUR )
{
status = 429;
res.send( status, "Error 429: Your IP address has reached or exceeded its API requests limit of " + config.limits.HOUR + " requests per hour." );
log.warn( "Client " + req.ip + " has reached its API requests limit of " + config.limits.HOUR + " requests per hour." );
}
else if ( config.limits.DAY != 0 && history.day >= config.limits.DAY )
{
status = 429;
res.send( status, "Error 429: Your IP address has reached or exceeded its API requests limit of " + config.limits.DAY + " requests per day." );
log.warn( "Client " + req.ip + " has reached its API requests limit of " + config.limits.DAY + " requests per day." );
}
// if limits haven't been met
else
{
var body = req.body,
url = req.body.url;
// if the URL is valid
if ( url && url.match( config.URL_PATTERN ) )
{
// if the URL is long enough to be shortened
if ( config.ALLOW_SHORT_URLS || ( !config.ALLOW_SHORT_URLS && isLongEnough( url ) ) )
{
// check to see if someone else already shortened that URL (@see database.js)
database.codeFromURL( url, function( code )
{
// if the URL is not in the database
if ( code == undefined )
{
// generate a unique URL code to be associated with this URL
uniqueCode( function( code )
{
database.insertURL( code, url, req.real_ip, function( newCode )
{
status = 201;
// return shortened URL to client
res.send( status, config.BASE_URL + newCode );
// log URL creation to console
log.info( "POST request to \"" + req.path + "\" - 201 Created: New URL shortened: " + ( config.BASE_URL + newCode ) );
// log API request to database
database.logInsert( newCode, status, req.real_ip );
});
});
}
// else if someone else already shortened this link
else
{
status = 200;
// return shortened URL to client
res.send( status, config.BASE_URL + code );
// log URL creation to console
log.info( "POST request to \"" + req.path + "\" - 200 OK: Already shortened URL returned: " + ( config.BASE_URL + code ) );
// log API request
database.logInsert( code, status, req.header("x-real-ip") );
}
});
}
// else if URL was already short enough
else
{
status = 400;
var message = "Error 400: The submitted URL is already as short enough and would not benefit from shortening.";
// send error to client
res.send( status, message );
// log error to console
log.warn( "POST request to \"" + req.path + "\" - " + message );
// log API request to database
database.logInsert( "", status, req.real_ip );
}
}
// if request body appeared to be blank
else if ( Object.keys( body ).length == 0 )
{
status = 400;
var message = "Error 400: The request body is empty or contains invalid JSON.";
// send error to client
res.send( status, message );
// log error to console
log.warn( "POST request to \"" + req.path + "\" - " + message );
// log API request to database
database.logInsert( "", status, req.real_ip );
}
// if the request body contained valid JSON with a "url" property BUT that property was left blank
else if ( url == undefined )
{
status = 400;
var message = "Error 400: The request does not contain an \"url\" property.";
// send error to client
res.send( status, message );
// log error to console
log.warn( "POST request to \"" + req.path + "\" - " + message );
// log API request to database
database.logInsert( "", status, req.real_ip );
}
// else if the URL was just invalid
else
{
status = 400;
var message = "Error 400: The following provided URL seems to be invalid: \"" + url + "\"";
// send error to client
res.send( status, message );
// log error to console
log.warn( "POST request to \"" + req.path + "\" - " + message );
// log api request to database
database.logInsert( "", status, req.real_ip );
}
}
});
});
/**
* Catches all other requests, returning Error 400 to the client
*/
app.all( "*", function( req, res )
{
// send error to client
res.send( 400, "Error 400: The request could not be fulfilled due to bad synthax. Please see the documentation on how to properly use this service's API." );
// log error to console
log.warn( "Error 400: Bad request to \"" + req.path + "\"" );
});