├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── app.js ├── config ├── env.js └── opts.js ├── lib ├── nus.js └── redis-model.js ├── package.json ├── public ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── css │ └── nus.css ├── favicon.ico ├── js │ └── nus.js └── robots.txt ├── routes ├── api.js └── index.js ├── test ├── app.test.js ├── nus.test.js └── redis.test.js └── views ├── error.ejs └── index.ejs /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store* 3 | Icon? 4 | ._* 5 | 6 | # Windows 7 | Thumbs.db 8 | ehthumbs.db 9 | Desktop.ini 10 | 11 | # Linux 12 | .directory 13 | *~ 14 | 15 | 16 | # npm 17 | node_modules 18 | package-lock.json 19 | *.log 20 | *.gz 21 | 22 | 23 | # Coveralls 24 | coverage 25 | 26 | # Benchmarking 27 | benchmarks/graphs 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | - "0.12" 5 | - "1.8" 6 | - "2.5" 7 | - "3.3" 8 | - "4.4" 9 | - "5.11" 10 | - "6.1" 11 | sudo: false 12 | cache: 13 | directories: 14 | - node_modules 15 | before_install: 16 | # Update Node.js modules 17 | - "test ! -d node_modules || npm prune" 18 | - "test ! -d node_modules || npm rebuild" 19 | script: 20 | - "npm test" 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 dotzero 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 deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | 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 all 13 | 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 FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node Url Shortener 2 | 3 | > A modern, minimalist, and lightweight URL shortener using [Node.js](http://nodejs.org) and [Redis](http://redis.io). 4 | 5 | [![Build Status](https://travis-ci.org/dotzero/node-url-shortener.svg?branch=master)](https://travis-ci.org/dotzero/node-url-shortener) 6 | [![GitHub tag](https://img.shields.io/github/tag/dotzero/node-url-shortener.svg)](https://github.com/dotzero/node-url-shortener) 7 | [![Dependency Status](https://david-dm.org/dotzero/node-url-shortener.svg)](https://david-dm.org/dotzero/node-url-shortener) 8 | 9 | ## Using 10 | 11 | * [Express 4](http://expressjs.com/) 12 | * [Redis](http://redis.io) 13 | 14 | ## Quick Start 15 | 16 | ```bash 17 | $ git clone git@github.com:dotzero/node-url-shortener.git 18 | $ cd nus 19 | $ npm install 20 | $ node app 21 | ``` 22 | 23 | ## Command Line Options 24 | 25 | ```bash 26 | $ node app -h 27 | 28 | Usage: app [options] 29 | 30 | Options: 31 | -u, --url Application URL [default: "http://127.0.0.1:3000"] 32 | -p, --port Port number for the Express application [default: 3000] 33 | --redis-host Redis Server hostname [default: "localhost"] 34 | --redis-port Redis Server port number [default: 6379] 35 | --redis-pass Redis Server password [default: false] 36 | --redis-db Redis DB index [default: 0] 37 | -h, --help Show help [boolean] 38 | ``` 39 | 40 | ## Installation on production 41 | 42 | ```bash 43 | $ git clone git@github.com:dotzero/node-url-shortener.git nus 44 | $ cd nus 45 | $ npm install --production 46 | $ NODE_ENV=production node app --url "http://example.com" 47 | ``` 48 | 49 | # RESTful API 50 | 51 | `POST /api/v1/shorten` with form data `long_url=http://google.com`, 52 | `start_date=""`, `end_date=""`, `c_new=false`. 53 | 54 | NOTE: You can send the post requests without the date and c_new params 55 | 56 | `POST /api/v1/shorten` with form data `long_url=http://google.com`, `start_date`="2017/06/19", `end_date`="2017/06/20", `c_new`=true 57 | 58 | The c_new paramter is do that it creates a new short url if one already exists for the url 59 | 60 | ```json 61 | { 62 | "hash": "rnRu", 63 | "long_url": "http://google.com", 64 | "short_url": "http://127.0.0.1:3000/rnRu", 65 | "status_code": 200, 66 | "status_txt": "OK" 67 | } 68 | ``` 69 | 70 | `GET /api/v1/expand/:hash` with query `rnRu` 71 | 72 | ```json 73 | { 74 | "start_date": "undefined", 75 | "end_date": "undefined", 76 | "hash": "rnRu", 77 | "long_url": "http://127.0.0.1:3000/rnRu", 78 | "clicks": "0", 79 | "status_code": 200, 80 | "status_txt": "OK" 81 | } 82 | ``` 83 | 84 | OR if dates are set 85 | 86 | ```json 87 | { 88 | "start_date": "2017/06/19", 89 | "end_date": "2017/06/20", 90 | "hash": "rnRu", 91 | "long_url": "http://127.0.0.1:3000/rnRu", 92 | "clicks": "0", 93 | "status_code": 200, 94 | "status_txt": "OK" 95 | } 96 | ``` 97 | 98 | ## Tests 99 | 100 | To run the test suite, first install the dependencies, then run `npm test`: 101 | 102 | ```bash 103 | $ npm install 104 | $ npm test 105 | ``` 106 | 107 | ## License 108 | 109 | Released under [the MIT license](LICENSE) 110 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | , app = express() 3 | , path = require('path') 4 | , opts = require(path.join(__dirname, 'config', 'opts.js')) 5 | , nus = require(path.join(__dirname, 'lib', 'nus.js'))(opts); 6 | 7 | // Gotta Catch 'Em All 8 | process.addListener('uncaughtException', function (err, stack) { 9 | console.log('Caught exception: ' + err + '\n' + err.stack); 10 | console.log('\u0007'); // Terminal bell 11 | }); 12 | 13 | // Common options 14 | app.set('__dirname', __dirname); 15 | app.set('opts', opts); 16 | app.set('x-powered-by', false); 17 | 18 | // Load express configuration 19 | require(path.join(__dirname, 'config', 'env.js'))(express, app); 20 | 21 | // Load routes 22 | require(path.join(__dirname, 'routes'))(app, nus); 23 | 24 | // Start HTTP server 25 | app.listen(opts.port, function () { 26 | console.log('Express server listening on port %d in %s mode', 27 | opts.port, app.settings.env 28 | ); 29 | console.log('Running on %s (Press CTRL+C to quit)', opts.url); 30 | }); 31 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | , cors = require('cors') 3 | , morgan = require('morgan') 4 | , bodyParser = require('body-parser') 5 | , methodOverride = require('method-override'); 6 | 7 | module.exports = function (express, app) { 8 | __dirname = app.get('__dirname'); 9 | 10 | // View engine setup 11 | app.set('view engine', 'ejs'); 12 | app.set('views', path.join(__dirname, 'views')); 13 | 14 | // Middleware 15 | app.use(cors()); 16 | app.use(morgan('dev')) 17 | app.use(bodyParser.urlencoded({ extended: false })); 18 | app.use(bodyParser.json()); 19 | app.use(methodOverride()); 20 | app.use(express.static(path.join(__dirname, 'public'))); 21 | }; 22 | -------------------------------------------------------------------------------- /config/opts.js: -------------------------------------------------------------------------------- 1 | module.exports = require('yargs') 2 | .usage('Usage: $0 [options]') 3 | .alias('u', 'url') 4 | .describe('u', 'Application URL') 5 | .default('u', 'http://127.0.0.1:3000') 6 | .alias('p', 'port') 7 | .describe('p', 'Port number for the Express application') 8 | .default('p', 3000) 9 | .describe('redis-host', 'Redis Server hostname') 10 | .default('redis-host', 'localhost') 11 | .describe('redis-port', 'Redis Server port number') 12 | .default('redis-port', 6379) 13 | .describe('redis-pass', 'Redis Server password') 14 | .default('redis-pass', false) 15 | .describe('redis-db', 'Redis DB index') 16 | .default('redis-db', 0) 17 | .help('h') 18 | .alias('h', 'help') 19 | .argv; 20 | -------------------------------------------------------------------------------- /lib/nus.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | 3 | module.exports = function (opts) { 4 | var self = {}; 5 | 6 | self.opts = opts || {}; 7 | self.opts.url = self.opts.url || 'http://127.0.0.1:3000'; 8 | self.opts.port = self.opts.port || 3000; 9 | self.opts['redis-host'] = self.opts['redis-host'] || 'localhost'; 10 | self.opts['redis-port'] = self.opts['redis-port'] || 6379; 11 | self.opts['redis-pass'] = self.opts['redis-pass'] || false; 12 | self.opts['redis-db'] = self.opts['redis-db'] || 0; 13 | 14 | self.checkDate = function(begin, end){ 15 | valid = true; 16 | 17 | if(+begin - +(new Date()) < 0){ 18 | valid = false; 19 | } 20 | 21 | if((+end - +begin) < 0){ 22 | valid = false 23 | }; 24 | 25 | return valid; 26 | } 27 | 28 | self.checkUrl = function (s, domain) { 29 | var regexp = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/ 30 | , valid = true; 31 | 32 | // Url correct 33 | if (regexp.test(s) !== true) { 34 | valid = false; 35 | } 36 | 37 | // Url equal application url 38 | if (valid === true && domain === true) { 39 | if (url.parse(self.opts.url).hostname === url.parse(s).hostname) { 40 | valid = false 41 | } 42 | } 43 | 44 | return valid; 45 | }; 46 | 47 | self.getModel = function (callback) { 48 | var RedisModel = require('./redis-model') 49 | , config = { 50 | host: self.opts['redis-host'], 51 | port: self.opts['redis-port'], 52 | pass: self.opts['redis-pass'], 53 | db: self.opts['redis-db'] 54 | }; 55 | 56 | callback(null, new RedisModel(config)); 57 | }; 58 | 59 | self.shorten = function (long_url, startDate, endDate, cNew, callback) { 60 | if (this.checkUrl(long_url, true)) { 61 | this.getModel(function (err, model) { 62 | if (err) { 63 | callback(500); 64 | } else { 65 | dateObj = startDate ? {'start_date': new Date(startDate), 'end_date' : new Date(endDate)} : {} 66 | model.set(long_url, dateObj, cNew, callback); 67 | } 68 | }); 69 | } else { 70 | callback(400); 71 | } 72 | }; 73 | 74 | self.expand = function (short_url, callback, click) { 75 | if (this.checkUrl(short_url)) { 76 | short_url = short_url.split('/').pop(); 77 | } 78 | 79 | if (short_url && /^[\w=]+$/.test(short_url)) { 80 | this.getModel(function (err, model) { 81 | if (err) { 82 | callback(500); 83 | } else { 84 | model.get(short_url, callback, click); 85 | } 86 | }); 87 | } else { 88 | callback(400); 89 | } 90 | }; 91 | 92 | return self; 93 | }; 94 | -------------------------------------------------------------------------------- /lib/redis-model.js: -------------------------------------------------------------------------------- 1 | var redis = require('redis'), 2 | base58 = require('base58'), 3 | crypto = require('crypto'); 4 | 5 | var RedisModel = module.exports = function (config, client) { 6 | if (config === null && client) { 7 | this.db = client; 8 | } else { 9 | var options = { 10 | host: config.host, 11 | port: config.port, 12 | db: config.db 13 | }; 14 | 15 | this.db = redis.createClient(options); 16 | 17 | if (config.pass) { 18 | this.db.auth(config.pass); 19 | } 20 | } 21 | }; 22 | 23 | var getRandomInt = function(min, max) { 24 | return Math.floor(Math.random() * (max - min)) + min; 25 | }; 26 | 27 | // General prefix 28 | RedisModel._prefix_ = 'nus:'; 29 | 30 | // Keys 31 | 32 | // nus:counter 33 | RedisModel.prototype.kCounter = function () { 34 | return RedisModel._prefix_ + 'counter'; 35 | }; 36 | 37 | // nus:url: 38 | RedisModel.prototype.kUrl = function (url) { 39 | return RedisModel._prefix_ + 'url:' + this.md5(url); 40 | }; 41 | 42 | // nus:hash: url 43 | // nus:hash: hash 44 | // nus:hash: clicks 45 | RedisModel.prototype.kHash = function (hash) { 46 | return RedisModel._prefix_ + 'hash:' + hash; 47 | }; 48 | 49 | // Helpers 50 | RedisModel.prototype.md5 = function (url) { 51 | return crypto.createHash('md5').update(url).digest('hex'); 52 | }; 53 | 54 | // Main methods 55 | RedisModel.prototype.uniqId = function (callback) { 56 | this.db.incr(this.kCounter(), function (err, reply) { 57 | var hash = base58.encode(getRandomInt(9999, 999999) + reply.toString()); 58 | if (typeof callback === 'function') { 59 | callback(err, hash); 60 | } 61 | }); 62 | }; 63 | 64 | RedisModel.prototype.findUrl = function (long_url, callback) { 65 | this.db.get(this.kUrl(long_url), function (err, reply) { 66 | if (typeof callback === 'function') { 67 | callback(err, reply); 68 | } 69 | }); 70 | }; 71 | 72 | RedisModel.prototype.findHash = function (short_url, callback) { 73 | this.db.hgetall(this.kHash(short_url), function (err, reply) { 74 | if (typeof callback === 'function') { 75 | callback(err, reply); 76 | } 77 | }); 78 | }; 79 | 80 | RedisModel.prototype.clickLink = function (short_url, callback) { 81 | this.db.hincrby(this.kHash(short_url), 'clicks', 1, function (err, reply) { 82 | if (typeof callback === 'function') { 83 | callback(err, reply); 84 | } 85 | }); 86 | }; 87 | 88 | String.prototype.boolean = function(str) { 89 | return "true" == str; 90 | }; 91 | 92 | // Set record 93 | RedisModel.prototype.set = function (long_url, datesObject, cNew, callback) { 94 | var self = this; 95 | this.findUrl(long_url, function (err, reply) { 96 | if (err) { 97 | callback(500); 98 | self.db.quit(); 99 | } else if (reply && cNew == 'false') { 100 | callback(null, { 101 | 'hash' : reply, 102 | 'long_url' : long_url 103 | }); 104 | self.db.quit(); 105 | } else { 106 | self.uniqId(function (err, hash) { 107 | if (err) { 108 | callback(500); 109 | self.db.quit(); 110 | } else { 111 | var response = { 112 | 'hash' : hash, 113 | 'long_url' : long_url 114 | }; 115 | 116 | self.db.multi([ 117 | ['set', self.kUrl(long_url), response.hash], 118 | ['hmset', self.kHash(response.hash), 119 | 'url', long_url, 120 | 'hash', response.hash, 121 | 'start_date', datesObject.start_date || 0, 122 | 'end_date', datesObject.end_date || 0, 123 | 'clicks', 0 124 | ] 125 | ]).exec(function (err, replies) { 126 | if (err) { 127 | callback(503); 128 | } else { 129 | callback(null, response); 130 | } 131 | self.db.quit(); 132 | }); 133 | } 134 | }); 135 | } 136 | }); 137 | }; 138 | 139 | // Get record 140 | RedisModel.prototype.get = function (short_url, callback, click) { 141 | var self = this; 142 | 143 | this.findHash(short_url, function (err, reply) { 144 | if (err) { 145 | callback(500); 146 | } else if (reply && 'url' in reply) { 147 | if (click) { 148 | self.clickLink(reply.hash); 149 | } 150 | callback(null, { 151 | 'start_date' : reply.start_date || 0, 152 | 'end_date' : reply.end_date || 0, 153 | 'hash' : reply.hash, 154 | 'long_url' : reply.url, 155 | 'clicks' : reply.clicks || 0 156 | }); 157 | } else { 158 | callback(404); 159 | } 160 | self.db.quit(); 161 | }); 162 | }; 163 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-url-shortener", 3 | "description": "A modern, minimalist, and lightweight URL shortener using Node.js and Redis", 4 | "version": "0.8.1", 5 | "author": "dotzero ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "http://github.com/dotzero/node-url-shortener" 10 | }, 11 | "dependencies": { 12 | "base58": "^1.0.1", 13 | "body-parser": "^1.18.2", 14 | "cors": "^2.8.4", 15 | "ejs": "^2.5.8", 16 | "express": "^4.16.3", 17 | "method-override": "^2.3.10", 18 | "morgan": "^1.9.0", 19 | "redis": "^2.8.0", 20 | "yargs": "^11.0.0" 21 | }, 22 | "devDependencies": { 23 | "expect.js": "^0.3.1", 24 | "fakeredis": "^1.0.3", 25 | "mocha": "^2.4.5", 26 | "superagent": "^2.0.0-alpha.3", 27 | "superagent-mocker": "^0.4.0" 28 | }, 29 | "keywords": [ 30 | "url", 31 | "shortener", 32 | "redis", 33 | "rest" 34 | ], 35 | "scripts": { 36 | "start": "node app", 37 | "test": "mocha" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zero-archive/node-url-shortener/1e5d04a68246ea8d6bcee80844953c1ae61cc041/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zero-archive/node-url-shortener/1e5d04a68246ea8d6bcee80844953c1ae61cc041/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/css/nus.css: -------------------------------------------------------------------------------- 1 | body{ 2 | background: #f5f5f5; 3 | } 4 | 5 | #header{ 6 | background: #00bfff; 7 | color: #ffffff; 8 | } 9 | 10 | .container { 11 | text-align: center; 12 | padding-top: 50px; 13 | } 14 | 15 | .url-input{ 16 | margin: auto; 17 | margin-bottom: 10px; 18 | } 19 | 20 | .url-form{ 21 | padding: 25px; 22 | } 23 | 24 | .flex-group{ 25 | display: flex; 26 | width: 45%; 27 | padding-left: 10px; 28 | padding-right: 10px; 29 | margin-left: 70px; 30 | } 31 | 32 | .flex-item{ 33 | flex: inline; 34 | margin-right: 10px; 35 | } 36 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zero-archive/node-url-shortener/1e5d04a68246ea8d6bcee80844953c1ae61cc041/public/favicon.ico -------------------------------------------------------------------------------- /public/js/nus.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | var _nus = function (data) { 3 | this._api_ = '/api/v1/shorten/'; 4 | this._form_ = '#nus'; 5 | this._s_date = '#start_date'; 6 | this._e_date = '#end_date'; 7 | this._link = '#link'; 8 | this._errormsg_ = 'An error occurred shortening that link, url format invalid.
' + 9 | 'Must be in format http(s)://weburl.com or http(s)://www.weburl.com'; 10 | }; 11 | 12 | _nus.prototype.init = function () { 13 | var c_new = false; 14 | this._start_date_ = $(this._s_date).val(); 15 | this._end_date_ = $(this._e_date).val(); 16 | this._url_ = $(this._link); 17 | if (!this.check(this._url_.val())) { 18 | return this.alert(this._errormsg_, true); 19 | } 20 | if(this._start_date_ !== ''){ 21 | c_new = true; 22 | } 23 | 24 | this.request(this._url_.val(), this._start_date_, this._end_date_, c_new); 25 | }; 26 | 27 | _nus.prototype.check = function (s) { 28 | var regexp = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/; 29 | return regexp.test(s); 30 | }; 31 | 32 | _nus.prototype.alert = function (message, error) { 33 | var t = error === true ? 'alert-danger' : 'alert-success'; 34 | 35 | $('.alert').alert('close'); 36 | $('').insertBefore(this._form_); 40 | }; 41 | 42 | _nus.prototype.request = function (url, s_date, e_date, cNew) { 43 | var self = this; 44 | $.post(self._api_, { long_url: url, start_date: s_date, end_date: e_date, c_new: cNew }, function (data) { 45 | if (data.hasOwnProperty('status_code') && data.hasOwnProperty('status_txt')) { 46 | if (parseInt(data.status_code) == 200) { 47 | 48 | self._url_.val(data.short_url).select(); 49 | return self.alert('Copy your shortened url'); 50 | } else { 51 | self._errormsg_ = data.status_txt; 52 | } 53 | } 54 | return self.alert(self._errormsg_, true); 55 | }).error(function () { 56 | return self.alert(self._errormsg_, true); 57 | }); 58 | }; 59 | 60 | $(function () { 61 | var n = new _nus(); 62 | var clipboard = new Clipboard('.btn'); 63 | 64 | $(n._form_).on('submit', function (e) { 65 | e && e.preventDefault(); 66 | n.init(); 67 | 68 | clipboard.on('success', function(e) { 69 | n.alert('Copied to clipboard!'); 70 | }); 71 | 72 | clipboard.on('error', function(e) { 73 | n.alert('Error copying to clipboard', true); 74 | }); 75 | }); 76 | }); 77 | 78 | })(window.jQuery); 79 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | module.exports = function (app, nus) { 2 | var opts = app.get('opts') 3 | , http = require('http') 4 | , router = require('express').Router(); 5 | 6 | router.route('/shorten') 7 | .post(function (req, res) { 8 | nus.shorten(req.body['long_url'], req.body['start_date'], req.body['end_date'], req.body['c_new'], function (err, reply) { 9 | if (err) { 10 | jsonResponse(res, err); 11 | } else if (reply) { 12 | reply.short_url = opts.url.replace(/\/$/, '') + '/' + reply.hash; 13 | jsonResponse(res, 200, reply); 14 | } else { 15 | jsonResponse(res, 500); 16 | } 17 | }); 18 | }); 19 | 20 | router.route('/expand') 21 | .post(function (req, res) { 22 | nus.expand(req.body['short_url'], function (err, reply) { 23 | if (err) { 24 | jsonResponse(res, err); 25 | } else if (reply) { 26 | jsonResponse(res, 200, reply); 27 | } else { 28 | jsonResponse(res, 500); 29 | } 30 | }); 31 | }); 32 | 33 | router.route('/expand/:short_url') 34 | .get(function (req, res) { 35 | nus.expand(req.params.short_url, function (err, reply) { 36 | if (err) { 37 | jsonResponse(res, err); 38 | } else if (reply) { 39 | startDate = reply.start_date || 0; 40 | endDate = reply.end_date || 0; 41 | toDay = new Date(); 42 | if((+startDate - +toDay) > 0 || (+endDate - +toDay) < 0 ){ 43 | err = {"error" : "sorry this url has expired"}; 44 | jsonResponse(res, 200, err); 45 | }else{ 46 | jsonResponse(res, 200, reply); 47 | } 48 | 49 | } else { 50 | jsonResponse(res, 500); 51 | } 52 | }); 53 | }); 54 | 55 | function jsonResponse (res, code, data) { 56 | data = data || {}; 57 | data.status_code = (http.STATUS_CODES[code]) ? code : 503, 58 | data.status_txt = http.STATUS_CODES[code] || http.STATUS_CODES[503] 59 | 60 | res.status(data.status_code).json(data) 61 | } 62 | 63 | return router; 64 | }; 65 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function (app, nus) { 2 | var opts = app.get('opts') 3 | , http = require('http') 4 | , api = require('./api.js')(app, nus); 5 | 6 | // api routes 7 | app.use('/api/v1', api); 8 | 9 | // index route 10 | app.route('/').all(function (req, res) { 11 | res.render('index'); 12 | }); 13 | 14 | // shorten route 15 | app.get(/^\/([\w=]+)$/, function (req, res, next){ 16 | nus.expand(req.params[0], function (err, reply) { 17 | if (err) { 18 | next(); 19 | } else { 20 | res.redirect(301, reply.long_url); 21 | } 22 | }, true); 23 | }); 24 | 25 | // catch 404 and forwarding to error handler 26 | app.use(function(req, res, next) { 27 | var err = new Error('Not Found'); 28 | err.status = 404; 29 | next(err); 30 | }); 31 | 32 | // development error handler 33 | // will print stacktrace 34 | if (app.get('env') === 'development') { 35 | app.use(function(err, req, res, next) { 36 | console.log('Caught exception: ' + err + '\n' + err.stack); 37 | res.status(err.status || 500); 38 | if (/^\/api\/v1/.test(req.originalUrl)) { 39 | res.json({ 40 | status_code: err.status || 500, 41 | status_txt: http.STATUS_CODES[err.status] || http.STATUS_CODES[500] 42 | }); 43 | } else { 44 | res.render('error', { 45 | code: err.status || 500, 46 | message: err.message, 47 | error: err 48 | }); 49 | } 50 | }); 51 | } 52 | 53 | // production error handler 54 | // no stacktraces leaked to user 55 | app.use(function(err, req, res, next) { 56 | res.status(err.status || 500); 57 | if (/^\/api\/v1/.test(req.originalUrl)) { 58 | res.json({ 59 | status_code: err.status || 500, 60 | status_txt: http.STATUS_CODES[err.status] || '' 61 | }); 62 | } else { 63 | res.render('error', { 64 | code: err.status || 500, 65 | message: err.message, 66 | error: false 67 | }); 68 | } 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /test/app.test.js: -------------------------------------------------------------------------------- 1 | var request = require('superagent') 2 | , mock = require('superagent-mocker')(request) 3 | , expect = require('expect.js'); 4 | 5 | describe('Test Node Url Shortener - RESTful API', function () { 6 | var id; 7 | 8 | beforeEach(function() { 9 | mock.clearRoutes(); 10 | mock.timeout = 0; 11 | }); 12 | 13 | it('should POST /api/v1/shorten', function (done) { 14 | mock.post('/api/v1/shorten', function(req) { 15 | return { 16 | hash: 'MQ==', 17 | long_url: req.body.long_url, 18 | short_url: 'http://localhost:3000/MQ==', 19 | start_date: "", 20 | end_date: "", 21 | c_new: false, 22 | status_code: 200, 23 | status_txt: 'OK', 24 | }; 25 | }); 26 | request.post('/api/v1/shorten', { 27 | long_url: 'https://www.google.com' 28 | }) 29 | .end(function(_, data) { 30 | expect(data).to.an('object'); 31 | expect(data).not.to.be.empty(); 32 | expect(data).to.have.keys('hash', 'long_url', 'short_url', 'status_code', 'status_txt', 'start_date', 'end_date'); 33 | id = data.hash; 34 | done(); 35 | }); 36 | }); 37 | 38 | it('should POST /api/v1/expand', function(done){ 39 | mock.post('/api/v1/expand', function(req) { 40 | return { 41 | hash: req.body.short_url, 42 | long_url: 'https://www.google.com', 43 | short_url: 'http://localhost:3000/' + req.body.short_url, 44 | start_date: req.body.start_date, 45 | end_date: req.body.end_date, 46 | status_code: 200, 47 | status_txt: 'OK', 48 | }; 49 | }); 50 | request.post('/api/v1/expand', { 51 | short_url: id 52 | }) 53 | .end(function(_, data) { 54 | expect(data).to.an('object'); 55 | expect(data).not.to.be.empty(); 56 | expect(data).to.have.keys('hash', 'long_url', 'short_url', 'status_code', 'status_txt', 'start_date', 'end_date'); 57 | done(); 58 | }) 59 | }); 60 | 61 | it('should GET /api/v1/expand/hash', function(done){ 62 | mock.get('/api/v1/expand/' + id, function(req) { 63 | return { 64 | hash: req.body.short_url, 65 | long_url: 'https://www.google.com', 66 | short_url: 'http://localhost:3000/' + req.body.short_url, 67 | start_date: req.body.start_date, 68 | end_date: req.body.end_date, 69 | status_code: 200, 70 | status_txt: 'OK', 71 | }; 72 | }); 73 | request.get('/api/v1/expand/' + id) 74 | .end(function(_, data) { 75 | expect(data).to.an('object'); 76 | expect(data).not.to.be.empty(); 77 | expect(data).to.have.keys('hash', 'long_url', 'short_url', 'status_code', 'status_txt', 'start_date', 'end_date'); 78 | done(); 79 | }) 80 | }); 81 | }) 82 | -------------------------------------------------------------------------------- /test/nus.test.js: -------------------------------------------------------------------------------- 1 | var request = require('superagent') 2 | , mock = require('superagent-mocker')(request) 3 | , expect = require('expect.js') 4 | , fakeredis; 5 | 6 | function addDays(n){ 7 | var t = new Date(); 8 | t.setDate(t.getDate() + n); 9 | var date = t.getFullYear()+"/"+(((t.getMonth() + 1) < 10 ) ? '0'+(t.getMonth()+1) : (t.getMonth()+1))+"/"+((t.getDate() < 10) ? '0'+t.getDate() : t.getDate()); 10 | return date; 11 | } 12 | 13 | describe('Test Node Url Shortener without start_date and end_date - Nus', function () { 14 | var nus 15 | , long_url 16 | , short_url 17 | , cNew; 18 | 19 | var dateObject = {}; 20 | 21 | beforeEach(function() { 22 | fakeredis = require('fakeredis').createClient(0, 'localhost', {fast : true}); 23 | nus = require('../lib/nus')(); 24 | nus.getModel = function (callback) { 25 | var RedisModel = require('../lib/redis-model.js'); 26 | callback(null, new RedisModel(null, fakeredis)); 27 | }; 28 | long_url = 'http://example.com'; 29 | short_url = 'foo'; 30 | dateObject.start_date = ''; 31 | dateObject.end_date = ''; 32 | cNew = 'false'; 33 | }); 34 | 35 | it('should shorten', function (done) { 36 | nus.shorten(long_url, dateObject.start_date, dateObject.end_date, cNew, function (err, reply) { 37 | expect(err).to.be(null); 38 | expect(reply).to.not.be.empty(); 39 | expect(reply).to.only.have.keys('hash', 'long_url'); 40 | expect(reply.hash).to.match(/[\w=]+/); 41 | expect(reply.long_url).to.be(long_url); 42 | done(); 43 | }); 44 | }); 45 | 46 | it('should expand', function (done) { 47 | nus.getModel(function (err, redis) { 48 | fakeredis.multi([ 49 | ['set', redis.kUrl(long_url), short_url], 50 | ['hmset', redis.kHash(short_url), 51 | 'url', long_url, 52 | 'hash', short_url, 53 | 'start_date', dateObject.start_date, 54 | 'end_date', dateObject.end_date, 55 | 'clicks', 1 56 | ] 57 | ]).exec(function (err, replies) { 58 | 59 | nus.shorten(long_url, dateObject.start_date, dateObject.end_date, cNew, function (err, reply) { 60 | expect(err).to.be(null); 61 | expect(reply).to.not.be.empty(); 62 | expect(reply).to.only.have.keys('hash', 'long_url'); 63 | expect(reply.hash).to.match(/[\w=]+/); 64 | expect(reply.long_url).to.be(long_url); 65 | done(); 66 | }); 67 | 68 | }); 69 | }); 70 | }); 71 | }); 72 | 73 | 74 | 75 | 76 | describe('Test Node Url Shortener with start_date and end_date - Nus', function () { 77 | var nus 78 | , long_url 79 | , short_url 80 | , cNew; 81 | 82 | var dateObject = {}; 83 | 84 | beforeEach(function() { 85 | fakeredis = require('fakeredis').createClient(0, 'localhost', {fast : true}); 86 | nus = require('../lib/nus')(); 87 | nus.getModel = function (callback) { 88 | var RedisModel = require('../lib/redis-model.js'); 89 | callback(null, new RedisModel(null, fakeredis)); 90 | }; 91 | long_url = 'http://example.com'; 92 | short_url = 'foo'; 93 | dateObject.start_date = addDays(0); 94 | dateObject.end_date = addDays(2); 95 | cNew = 'true'; 96 | }); 97 | 98 | it('should shorten', function (done) { 99 | nus.shorten(long_url, dateObject.start_date, dateObject.end_date, cNew, function (err, reply) { 100 | expect(err).to.be(null); 101 | expect(reply).to.not.be.empty(); 102 | expect(reply).to.only.have.keys('hash', 'long_url'); 103 | expect(reply.hash).to.match(/[\w=]+/); 104 | expect(reply.long_url).to.be(long_url); 105 | done(); 106 | }); 107 | }); 108 | 109 | it('should expand', function (done) { 110 | nus.getModel(function (err, redis) { 111 | fakeredis.multi([ 112 | ['set', redis.kUrl(long_url), short_url], 113 | ['hmset', redis.kHash(short_url), 114 | 'url', long_url, 115 | 'hash', short_url, 116 | 'start_date', dateObject.start_date, 117 | 'end_date', dateObject.end_date, 118 | 'clicks', 1 119 | ] 120 | ]).exec(function (err, replies) { 121 | 122 | nus.shorten(long_url, dateObject.start_date, dateObject.end_date, cNew, function (err, reply) { 123 | expect(err).to.be(null); 124 | expect(reply).to.not.be.empty(); 125 | expect(reply).to.only.have.keys('hash', 'long_url'); 126 | expect(reply.hash).to.match(/[\w=]+/); 127 | expect(reply.long_url).to.be(long_url); 128 | done(); 129 | }); 130 | 131 | }); 132 | }); 133 | }); 134 | }) 135 | 136 | -------------------------------------------------------------------------------- /test/redis.test.js: -------------------------------------------------------------------------------- 1 | var request = require('superagent') 2 | , mock = require('superagent-mocker')(request) 3 | , expect = require('expect.js') 4 | , RedisModel = require('../lib/redis-model.js') 5 | , fakeredis; 6 | 7 | 8 | function addDays(n){ 9 | var t = new Date(); 10 | t.setDate(t.getDate() + n); 11 | var date = t.getFullYear()+"/"+(((t.getMonth() + 1) < 10 ) ? '0'+(t.getMonth()+1) : (t.getMonth()+1))+"/"+((t.getDate() < 10) ? '0'+t.getDate() : t.getDate()); 12 | return date; 13 | } 14 | 15 | describe('Test Node Url Shortener Without Dates - RedisModel', function () { 16 | var redis 17 | , prefix 18 | , long_url 19 | , short_url 20 | , cNew; 21 | 22 | var dateObject = {}; 23 | 24 | beforeEach(function() { 25 | fakeredis = require('fakeredis').createClient(0, 'localhost', {fast : true}); 26 | redis = new RedisModel(null, fakeredis); 27 | prefix = RedisModel._prefix_; 28 | long_url = 'http://example.com'; 29 | short_url = 'foo' 30 | dateObject.start_date = ''; 31 | dateObject.end_date = ''; 32 | }); 33 | 34 | it('kCounter should return Redis key', function (done) { 35 | var data = redis.kCounter(); 36 | expect(data).to.be.a('string'); 37 | expect(data).to.be(prefix + 'counter'); 38 | done(); 39 | }); 40 | 41 | it('kUrl should return Redis key', function (done) { 42 | var data = redis.kUrl(long_url); 43 | expect(data).to.be.a('string'); 44 | expect(data).to.be(prefix + 'url:a9b9f04336ce0181a08e774e01113b31'); 45 | done(); 46 | }); 47 | 48 | it('kHash should return Redis key', function (done) { 49 | var data = redis.kHash(short_url); 50 | expect(data).to.be.a('string'); 51 | expect(data).to.be(prefix + 'hash:foo'); 52 | done(); 53 | }); 54 | 55 | it('md5 should return MD5 hash', function (done) { 56 | var data = redis.md5(long_url); 57 | expect(data).to.be.a('string'); 58 | expect(data).to.be('a9b9f04336ce0181a08e774e01113b31'); 59 | done(); 60 | }); 61 | 62 | it('uniqId should return unique Redis key', function (done) { 63 | redis.uniqId(function(err, hash) { 64 | expect(err).to.be(null); 65 | expect(hash).to.be.a('string'); 66 | expect(hash).to.match(/[\w=]+/); 67 | done(); 68 | }); 69 | }); 70 | 71 | it('findUrl should return Redis value', function (done) { 72 | fakeredis.multi([ 73 | ['set', redis.kUrl(long_url), short_url], 74 | ['hmset', redis.kHash(short_url), 75 | 'url', long_url, 76 | 'hash', short_url, 77 | 'start_date', dateObject.start_date, 78 | 'end_date', dateObject.end_date, 79 | 'clicks', 1 80 | ] 81 | ]).exec(function (err, replies) { 82 | 83 | redis.findUrl(long_url, function(err, reply) { 84 | expect(err).to.be(null); 85 | expect(reply).to.be.a('string'); 86 | expect(reply).to.be(short_url); 87 | done(); 88 | }); 89 | 90 | }); 91 | }); 92 | 93 | it('findHash should return Redis value', function (done) { 94 | fakeredis.multi([ 95 | ['set', redis.kUrl(long_url), short_url], 96 | ['hmset', redis.kHash(short_url), 97 | 'url', long_url, 98 | 'hash', short_url, 99 | 'start_date', dateObject.start_date, 100 | 'end_date', dateObject.end_date, 101 | 'clicks', 1 102 | ] 103 | ]).exec(function (err, replies) { 104 | 105 | redis.findHash(short_url, function(err, reply) { 106 | expect(err).to.be(null); 107 | expect(reply).to.not.be.empty(); 108 | expect(reply).to.only.have.keys('clicks', 'hash', 'url', 'start_date', 'end_date'); 109 | expect(reply.hash).to.be(short_url); 110 | expect(reply.url).to.be(long_url); 111 | done(); 112 | }); 113 | 114 | }); 115 | }); 116 | 117 | it('clickLink should return 2', function (done) { 118 | fakeredis.multi([ 119 | ['set', redis.kUrl(long_url), short_url], 120 | ['hmset', redis.kHash(short_url), 121 | 'url', long_url, 122 | 'hash', short_url, 123 | 'start_date', dateObject.start_date, 124 | 'end_date', dateObject.end_date, 125 | 'clicks', 1 126 | ] 127 | ]).exec(function (err, replies) { 128 | 129 | redis.clickLink(short_url, function(err, reply) { 130 | expect(err).to.be(null); 131 | expect(reply).to.be(2) 132 | done(); 133 | }); 134 | 135 | }); 136 | }); 137 | 138 | it('set should return Redis value', function (done) { 139 | redis.set(long_url, dateObject, cNew, function(err, reply) { 140 | expect(err).to.be(null); 141 | expect(reply).to.not.be.empty(); 142 | expect(reply).to.only.have.keys('hash', 'long_url'); 143 | expect(reply.hash).to.be.a('string'); 144 | expect(reply.long_url).to.be(long_url); 145 | done(); 146 | }); 147 | }); 148 | 149 | it('get should return Redis value', function (done) { 150 | fakeredis.multi([ 151 | ['set', redis.kUrl(long_url), short_url], 152 | ['hmset', redis.kHash(short_url), 153 | 'url', long_url, 154 | 'hash', short_url, 155 | 'start_date', dateObject.start_date, 156 | 'end_date', dateObject.end_date, 157 | 'clicks', 1 158 | ] 159 | ]).exec(function (err, replies) { 160 | 161 | redis.get(short_url, function(err, reply) { 162 | expect(err).to.be(null); 163 | expect(reply).to.not.be.empty(); 164 | expect(reply).to.only.have.keys('hash', 'long_url', 'clicks', 'start_date', 'end_date'); 165 | expect(reply.hash).to.be(short_url); 166 | expect(reply.long_url).to.be(long_url); 167 | done(); 168 | }); 169 | 170 | }); 171 | }); 172 | }); 173 | 174 | 175 | describe('Test Node Url Shortener With Dates - RedisModel', function () { 176 | var redis 177 | , prefix 178 | , long_url 179 | , short_url 180 | , cNew; 181 | 182 | var dateObject = {}; 183 | 184 | beforeEach(function() { 185 | fakeredis = require('fakeredis').createClient(0, 'localhost', {fast : true}); 186 | redis = new RedisModel(null, fakeredis); 187 | prefix = RedisModel._prefix_; 188 | long_url = 'http://example.com'; 189 | short_url = 'foo' 190 | dateObject.start_date = addDays(0); 191 | dateObject.end_date = addDays(2); 192 | }); 193 | 194 | it('kCounter should return Redis key', function (done) { 195 | var data = redis.kCounter(); 196 | expect(data).to.be.a('string'); 197 | expect(data).to.be(prefix + 'counter'); 198 | done(); 199 | }); 200 | 201 | it('kUrl should return Redis key', function (done) { 202 | var data = redis.kUrl(long_url); 203 | expect(data).to.be.a('string'); 204 | expect(data).to.be(prefix + 'url:a9b9f04336ce0181a08e774e01113b31'); 205 | done(); 206 | }); 207 | 208 | it('kHash should return Redis key', function (done) { 209 | var data = redis.kHash(short_url); 210 | expect(data).to.be.a('string'); 211 | expect(data).to.be(prefix + 'hash:foo'); 212 | done(); 213 | }); 214 | 215 | it('md5 should return MD5 hash', function (done) { 216 | var data = redis.md5(long_url); 217 | expect(data).to.be.a('string'); 218 | expect(data).to.be('a9b9f04336ce0181a08e774e01113b31'); 219 | done(); 220 | }); 221 | 222 | it('uniqId should return unique Redis key', function (done) { 223 | redis.uniqId(function(err, hash) { 224 | expect(err).to.be(null); 225 | expect(hash).to.be.a('string'); 226 | expect(hash).to.match(/[\w=]+/); 227 | done(); 228 | }); 229 | }); 230 | 231 | it('findUrl should return Redis value', function (done) { 232 | fakeredis.multi([ 233 | ['set', redis.kUrl(long_url), short_url], 234 | ['hmset', redis.kHash(short_url), 235 | 'url', long_url, 236 | 'hash', short_url, 237 | 'start_date', dateObject.start_date, 238 | 'end_date', dateObject.end_date, 239 | 'clicks', 1 240 | ] 241 | ]).exec(function (err, replies) { 242 | 243 | redis.findUrl(long_url, function(err, reply) { 244 | expect(err).to.be(null); 245 | expect(reply).to.be.a('string'); 246 | expect(reply).to.be(short_url); 247 | done(); 248 | }); 249 | 250 | }); 251 | }); 252 | 253 | it('findHash should return Redis value', function (done) { 254 | fakeredis.multi([ 255 | ['set', redis.kUrl(long_url), short_url], 256 | ['hmset', redis.kHash(short_url), 257 | 'url', long_url, 258 | 'hash', short_url, 259 | 'start_date', dateObject.start_date, 260 | 'end_date', dateObject.end_date, 261 | 'clicks', 1 262 | ] 263 | ]).exec(function (err, replies) { 264 | 265 | redis.findHash(short_url, function(err, reply) { 266 | expect(err).to.be(null); 267 | expect(reply).to.not.be.empty(); 268 | expect(reply).to.only.have.keys('clicks', 'hash', 'url', 'start_date', 'end_date'); 269 | expect(reply.hash).to.be(short_url); 270 | expect(reply.url).to.be(long_url); 271 | done(); 272 | }); 273 | 274 | }); 275 | }); 276 | 277 | it('clickLink should return 2', function (done) { 278 | fakeredis.multi([ 279 | ['set', redis.kUrl(long_url), short_url], 280 | ['hmset', redis.kHash(short_url), 281 | 'url', long_url, 282 | 'hash', short_url, 283 | 'start_date', dateObject.start_date, 284 | 'end_date', dateObject.end_date, 285 | 'clicks', 1 286 | ] 287 | ]).exec(function (err, replies) { 288 | 289 | redis.clickLink(short_url, function(err, reply) { 290 | expect(err).to.be(null); 291 | expect(reply).to.be(2) 292 | done(); 293 | }); 294 | 295 | }); 296 | }); 297 | 298 | it('set should return Redis value', function (done) { 299 | redis.set(long_url, dateObject, cNew, function(err, reply) { 300 | expect(err).to.be(null); 301 | expect(reply).to.not.be.empty(); 302 | expect(reply).to.only.have.keys('hash', 'long_url'); 303 | expect(reply.hash).to.be.a('string'); 304 | expect(reply.long_url).to.be(long_url); 305 | done(); 306 | }); 307 | }); 308 | 309 | it('get should return Redis value', function (done) { 310 | fakeredis.multi([ 311 | ['set', redis.kUrl(long_url), short_url], 312 | ['hmset', redis.kHash(short_url), 313 | 'url', long_url, 314 | 'hash', short_url, 315 | 'start_date', dateObject.start_date, 316 | 'end_date', dateObject.end_date, 317 | 'clicks', 1 318 | ] 319 | ]).exec(function (err, replies) { 320 | 321 | redis.get(short_url, function(err, reply) { 322 | expect(err).to.be(null); 323 | expect(reply).to.not.be.empty(); 324 | expect(reply).to.only.have.keys('hash', 'long_url', 'clicks', 'start_date', 'end_date'); 325 | expect(reply.hash).to.be(short_url); 326 | expect(reply.long_url).to.be(long_url); 327 | expect(reply.start_date).to.not.be.empty(); 328 | expect(reply.end_date).to.not.be.empty(); 329 | done(); 330 | }); 331 | 332 | }); 333 | }); 334 | }) 335 | 336 | -------------------------------------------------------------------------------- /views/error.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= code %> <%= message %> 8 | 9 | 10 | 11 | 12 | 16 | 17 | 18 |
19 | 20 |
21 |

<%= code %>

22 |

<%= message %>

23 |
24 | 25 | <% if (error) { %> 26 |
<%= error.stack %>
27 | <% } %> 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Node Url Shortener 8 | 9 | 10 | 11 | 12 | 16 | 17 | 18 | 24 | 25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | 37 | 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 | --------------------------------------------------------------------------------