├── .eslintrc.json ├── .gitignore ├── .nowignore ├── LICENSE.md ├── README.md ├── back ├── handlers │ ├── chop.js │ └── redirect.js ├── helpers │ ├── short.js │ └── uuid.js ├── middlewares │ ├── acceptedContentType.js │ ├── errorHandler.js │ └── responseTime.js └── models │ └── Short.js ├── front ├── index.html ├── scripts.js └── styles.css ├── now.json └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "globals": { 4 | "fetch": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /.nowignore: -------------------------------------------------------------------------------- 1 | *.md 2 | .eslitrc* 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 paulogdm 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 | # Chop My Url! 2 | 3 | A shortener URL example deployed to [Now 2.0](https://now.sh/). 4 | 5 | Live demo: [chop.now.sh](https://chop.now.sh). The source and logs can be accessed by attaching `/_src` or `/_logs`, respectively. 6 | 7 | ## Getting Started 8 | 9 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on [Now](https://now.sh/). 10 | 11 | ### Prerequisites 12 | 13 | To run this example locally, you need [NodeJS](https://nodejs.org/). 14 | 15 | Testing each API endpoint individually: 16 | 17 | ```shell 18 | $ npm i 19 | $ mongo_chop_user=yourdbuser mongo_chop_passnode=yourdbpass node back/handlers/chop.js 20 | $ mongo_chop_user=yourdbuser mongo_chop_passnode=yourdbpass node back/handlers/redirect.js 21 | ``` 22 | 23 | ## Running the tests 24 | 25 | Tests can be run by executing: 26 | 27 | ``` 28 | npm run test 29 | ``` 30 | 31 | ## Deployment 32 | 33 | To deploy this example, you need to install `now-cli`: 34 | 35 | ``` 36 | $ npm i -g now 37 | ``` 38 | 39 | Then, simply: 40 | 41 | ``` 42 | $ now 43 | ``` 44 | 45 | ## Built With 46 | 47 | * [Koa](https://koajs.com/) - Next gen web framework for Node 48 | * [Milligram](https://milligram.io/) - A minimalist CSS framework 49 | * [Mongoose](https://mongoosejs.com/) - Elegant mongodb object modeling for node.js 50 | 51 | ## License 52 | 53 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 54 | -------------------------------------------------------------------------------- /back/handlers/chop.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa') 2 | const koaBody = require('koa-body') 3 | 4 | const responseTime = require('../middlewares/responseTime') 5 | const errorHandler = require('../middlewares/errorHandler') 6 | const acceptedContentType = require('../middlewares/acceptedContentType') 7 | 8 | const shortActions = require('../helpers/short') 9 | 10 | const app = new Koa() 11 | app.use(koaBody({ jsonLimit: '1kb' })) 12 | app.use(responseTime) 13 | app.use(errorHandler.use) 14 | app.on('Error', errorHandler.on) 15 | app.use(acceptedContentType) 16 | 17 | const main = async ctx => { 18 | const { originalUrl } = ctx.request.body 19 | // mongo insert 20 | const { shortPath } = await shortActions.insert(originalUrl) 21 | // ctx.request.origin may no be suited for all cases 22 | // but here we glue the path with the origin url: 23 | // eg: https://chop.now.sh + / + abcdefghij 24 | const shortUrl = `${ctx.request.origin}/${shortPath}` 25 | ctx.body = { originalUrl, shortUrl } 26 | } 27 | 28 | app.use(main) 29 | 30 | // to test locally, is there a better way? 31 | if (!process.env.NOW_REGION) app.listen(3000) 32 | module.exports = app.callback() 33 | -------------------------------------------------------------------------------- /back/handlers/redirect.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa') 2 | const responseTime = require('../middlewares/responseTime') 3 | const errorHandler = require('../middlewares/errorHandler') 4 | const shortActions = require('../helpers/short') 5 | 6 | const app = new Koa() 7 | 8 | app.use(responseTime) 9 | app.use(errorHandler.use) 10 | app.on('Error', errorHandler.on) 11 | 12 | /** 13 | * Function that handles a failed request for a short url 14 | * @param {Object} ctx Koa's context 15 | */ 16 | const notFound = async ctx => { 17 | ctx.status = 404 18 | } 19 | 20 | /** 21 | * This function redirects the request to another location 22 | * @param {Object} ctx Koa's context 23 | * @param {String} originalUrl URL stored in mongo 24 | */ 25 | const redirect = async (ctx, originalUrl) => { 26 | let location = originalUrl 27 | 28 | // handles cases like example.com 29 | if (!location.startsWith('https://') && !location.startsWith('http://')) { 30 | location = `https://${location}` 31 | } 32 | 33 | ctx.status = 301 34 | ctx.redirect(location) 35 | ctx.body = 'Redirecting...' 36 | } 37 | 38 | /** 39 | * Main function. It handles the request by searching for an entry 40 | * in the database. If it exists, redirect, else, return not found. 41 | * @param {Object} ctx Koa's context 42 | */ 43 | const main = async ctx => { 44 | // example: https://chop.now.sh/abcdef => short := abcef 45 | const short = ctx.request.url.split('/').pop() 46 | // query db 47 | const document = await shortActions.getByShortPath(short) 48 | 49 | return document ? redirect(ctx, document.originalUrl) : notFound(ctx) 50 | } 51 | 52 | app.use(main) 53 | 54 | // to test locally, is there a better way? 55 | if (!process.env.NOW_REGION) app.listen(3000) 56 | module.exports = app.callback() 57 | -------------------------------------------------------------------------------- /back/helpers/short.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const Short = require('../models/Short.js') 4 | const uuid = require('./uuid.js') 5 | 6 | // https://zeit.co/docs/v2/deployments/environment-variables-and-secrets#from-the-cli 7 | const dbuser = process.env.mongo_chop_user 8 | const dbpassword = process.env.mongo_chop_pass 9 | 10 | // Remember to add your own secrets!!! 11 | if (!dbuser || !dbpassword) { 12 | throw new Error(`A user and password is required! Got user:${dbuser} and password:${dbpassword}`) 13 | } 14 | 15 | // connecting to mongo 16 | mongoose.connect(`mongodb://${dbuser}:${dbpassword}@ds115154.mlab.com:15154/chop`, { 17 | useNewUrlParser: true 18 | }) 19 | 20 | const getByShortPath = async (shortPath) => Short.findOne({ shortPath }).exec() 21 | 22 | const getByOriginalUrl = async (originalUrl) => Short.findOne({ originalUrl }).exec() 23 | 24 | const insert = async (originalUrl) => { 25 | let exists 26 | let shortPath 27 | // this loop should not run in most cases... 28 | // in case of collision, generate another uuid 29 | do { 30 | shortPath = uuid() 31 | exists = await getByShortPath(shortPath) 32 | } while (exists) 33 | 34 | const newDocument = new Short({ shortPath, originalUrl }) 35 | newDocument.save() 36 | 37 | return { originalUrl, shortPath } 38 | } 39 | 40 | module.exports = { 41 | getByShortPath, 42 | getByOriginalUrl, 43 | insert 44 | } 45 | -------------------------------------------------------------------------------- /back/helpers/uuid.js: -------------------------------------------------------------------------------- 1 | const uuidv4 = require('uuid/v4') 2 | 3 | // we can change this in the future for a hash function, if needed. 4 | module.exports = () => uuidv4().split('-').shift() 5 | -------------------------------------------------------------------------------- /back/middlewares/acceptedContentType.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This middleware ensures that we are receiving a proper body. 3 | */ 4 | module.exports = async (ctx, next) => { 5 | if (ctx.is('application/json')) { 6 | await next() 7 | } else { 8 | ctx.status = 406 9 | ctx.accepts('application/json') 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /back/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This middleware is a central error handler for our app. 3 | */ 4 | const use = async (ctx, next) => { 5 | try { 6 | await next() 7 | } catch (err) { 8 | ctx.status = err.status || 500 9 | ctx.body = err.message 10 | ctx.app.emit('Error', err, ctx) 11 | } 12 | } 13 | 14 | const on = (err, ctx) => { 15 | console.error(err) 16 | } 17 | 18 | module.exports = { 19 | use, 20 | on 21 | } 22 | -------------------------------------------------------------------------------- /back/middlewares/responseTime.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This middleware benchmarks the response time of the server. 3 | */ 4 | module.exports = async (ctx, next) => { 5 | const start = Date.now() 6 | await next() 7 | const ms = Date.now() - start 8 | ctx.set('X-Response-Time', `${ms}ms`) 9 | } 10 | -------------------------------------------------------------------------------- /back/models/Short.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const Short = mongoose.model('Short', { 4 | originalUrl: { 5 | type: String 6 | }, 7 | shortPath: { 8 | type: String, 9 | unique: true, 10 | index: true 11 | } 12 | }) 13 | 14 | module.exports = Short 15 | -------------------------------------------------------------------------------- /front/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chop it! 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |

Chop My Url

30 |
Shortener URL based on Now 2
31 |
32 | 33 | 34 |
35 |
36 | 40 |
41 |
42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /front/scripts.js: -------------------------------------------------------------------------------- 1 | const POST_URL = '/' 2 | let LATEST_SHORT = null 3 | 4 | /** 5 | * This function is responsible for a POST request to our API. 6 | * @return {[type]} [description] 7 | */ 8 | const send = async () => { 9 | // retrieving value from DOM 10 | const input = document.getElementById('input').value 11 | 12 | // any problems, including parsing issues, will be handled 13 | try { 14 | const rawResponse = await fetch(POST_URL, { 15 | method: 'POST', 16 | headers: { 17 | 'Accept': 'application/json', 18 | 'Content-Type': 'application/json' 19 | }, 20 | body: JSON.stringify({ originalUrl: input }) 21 | }) 22 | // parsing the raw response to a json format 23 | const { shortUrl } = await rawResponse.json() 24 | // set global variable 25 | LATEST_SHORT = shortUrl 26 | } catch (err) { 27 | console.error('🚑', `${err}`) 28 | } 29 | 30 | // after our request, it should update the DOM to show the short url 31 | updateCopyField() 32 | } 33 | 34 | /** 35 | * A simple helper to update the DOM, showing the short url. 36 | */ 37 | const updateCopyField = () => { 38 | // if the variable exists and is different from undefined. 39 | if (LATEST_SHORT) { 40 | const target = document.getElementById('output-url') 41 | document.getElementById('output').classList.remove('hidden') 42 | target.innerText = LATEST_SHORT 43 | } 44 | } 45 | 46 | /** 47 | * It saves the short url to the user clipboard. 48 | */ 49 | const copy = async () => { 50 | // if the variable exists and is different from undefined. 51 | if (LATEST_SHORT) { 52 | // querying the DOM element 53 | const target = document.getElementById('output') 54 | 55 | // animating with Animate.css 56 | target.classList.add('animated') 57 | target.classList.add('rubberBand') 58 | 59 | // remove after animation stops 60 | window.setTimeout(() => { 61 | target.classList.remove('animated') 62 | target.classList.remove('rubberBand') 63 | }, 1000) 64 | 65 | await navigator.clipboard.writeText(LATEST_SHORT) 66 | } 67 | } 68 | 69 | 70 | /** 71 | * attach events on load 72 | */ 73 | window.addEventListener('load', () => { 74 | // add events 75 | document.getElementById('chop-it').addEventListener('click', send) 76 | document.getElementById('output').addEventListener('click', copy) 77 | }) 78 | -------------------------------------------------------------------------------- /front/styles.css: -------------------------------------------------------------------------------- 1 | 2 | /* Mobile First Media Queries */ 3 | 4 | body { 5 | font-family: 'Roboto Slab', serif; 6 | display: grid; 7 | height: 100vh; 8 | margin: 0; 9 | place-items: center center; 10 | background-color: #f4f5f6; 11 | } 12 | 13 | .button-cover { 14 | font-size: 1.4rem; 15 | height: 4.5rem; 16 | line-height: 4.5rem; 17 | padding: 0 2rem; 18 | width: 100%; 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | } 23 | 24 | h1, h6 { 25 | text-align: center; 26 | } 27 | 28 | .hidden { 29 | display: none; 30 | } 31 | 32 | #input { 33 | background-color: white; 34 | } 35 | 36 | #output svg { 37 | width: 1.5rem; 38 | margin: 0 0.2rem; 39 | } 40 | 41 | #output svg path { 42 | fill: currentColor; 43 | } 44 | 45 | /* Larger than mobile screen */ 46 | @media (min-width: 40.0rem) { 47 | 48 | } 49 | 50 | /* Larger than tablet screen */ 51 | @media (min-width: 80.0rem) { 52 | 53 | } 54 | 55 | /* Larger than desktop screen */ 56 | @media (min-width: 120.0rem) { 57 | 58 | } 59 | 60 | /* https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.7.0/animate.css */ 61 | .animated { 62 | animation-duration: 1s; 63 | animation-fill-mode: both; 64 | } 65 | 66 | @keyframes rubberBand { 67 | from { 68 | transform: scale3d(1, 1, 1); 69 | } 70 | 71 | 30% { 72 | transform: scale3d(1.25, 0.75, 1); 73 | } 74 | 75 | 40% { 76 | transform: scale3d(0.75, 1.25, 1); 77 | } 78 | 79 | 50% { 80 | transform: scale3d(1.15, 0.85, 1); 81 | } 82 | 83 | 65% { 84 | transform: scale3d(0.95, 1.05, 1); 85 | } 86 | 87 | 75% { 88 | transform: scale3d(1.05, 0.95, 1); 89 | } 90 | 91 | to { 92 | transform: scale3d(1, 1, 1); 93 | } 94 | } 95 | 96 | .rubberBand { 97 | animation-name: rubberBand; 98 | } 99 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "chop-my-url", 4 | "public": true, 5 | "alias": "chop", 6 | "builds": [ 7 | { "src": "back/handlers/*.js", "use": "@now/node" }, 8 | { "src": "front/**", "use": "@now/static" } 9 | ], 10 | "routes": [ 11 | { "src": "/", "methods": ["GET"] , "dest": "/front/index.html" }, 12 | { "src": "/", "methods": ["POST"] ,"dest": "/back/handlers/chop.js" }, 13 | { "src": "/([a-zA-Z0-9]{8})", "methods": ["GET"] ,"dest": "/back/handlers/redirect.js" }, 14 | { "src": "/(.*(js|css|ico))", "methods": ["GET"] ,"dest": "/front/$1" } 15 | ], 16 | "env": { 17 | "mongo_chop_user": "@mongo_chop_user", 18 | "mongo_chop_pass": "@mongo_chop_pass" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chop-my-url", 3 | "version": "0.0.1", 4 | "description": "A tiny example showing how to use now v2.", 5 | "scripts": { 6 | "test": "ava" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/paulogdm/chop-my-url.git" 11 | }, 12 | "author": "paulogdm", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/paulogdm/chop-my-url/issues" 16 | }, 17 | "homepage": "https://github.com/paulogdm/chop-my-url#readme", 18 | "dependencies": { 19 | "koa": "^2.6.2", 20 | "koa-body": "^4.0.4", 21 | "mongoose": "^5.3.13", 22 | "uuid": "^3.3.2" 23 | }, 24 | "devDependencies": { 25 | "eslint": "^5.9.0", 26 | "eslint-config-standard": "^12.0.0", 27 | "eslint-plugin-import": "^2.14.0", 28 | "eslint-plugin-node": "^8.0.0", 29 | "eslint-plugin-promise": "^4.0.1", 30 | "eslint-plugin-standard": "^4.0.0" 31 | } 32 | } 33 | --------------------------------------------------------------------------------