├── .env.sample ├── .gitignore ├── README.md ├── functions ├── details.js ├── go.js └── short-url.js ├── netlify.toml ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── css │ └── main.css ├── img │ ├── backgrounds │ │ ├── bg-header.png │ │ └── partners.png │ └── sprites │ │ └── bg-brazillian-creators.png ├── index.html └── js │ └── main.js ├── tailwind.config.js └── yarn.lock /.env.sample: -------------------------------------------------------------------------------- 1 | REDIS_HOST=your_redis_host 2 | REDIS_PORT=your_redis_port 3 | REDIS_PASSWORD=your_redis_password 4 | BASE_URL=https://serverless-url-shortener-upstash.netlify.app 5 | RECAPTCHA_PRIVATE_KEY=your_google_key -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | dist/ 3 | node_modules/ 4 | # Local Netlify folder 5 | .netlify -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Short URL with Serverless + Redis + Netlify 2 | ### Create your own "short url" service in minutes, not days. 3 | ##### Try it here: https://serverless-url-shortener-upstash.netlify.app 4 | --- 5 | This project aims to create a scalable and open-source solution alternative to Bit.ly, Tinyurl, Goo.gl, etc. If you want: 6 | 7 | - Create unlimited shorted urls in your own cloud 8 | - Use a custom domain of yours 9 | - Scalable and cheap solution 10 | - ✨Magic ✨ 11 | 12 | This is **exactly** for you. 13 | 14 | ## How to install 15 | The installation is simple once you are registered in **Netlify**. Please note that you can easily change this serverless function to work with other alternatives if you are used to serverless functions (basically just change the entry point and the request body). 16 | 17 | ### Requirements 18 | - Create an account in Netlify (it's free) 19 | - Create an account in Upstash to use a free Redis database (or use your own cloud Redis solution) 20 | - This project was made using Node v14.15.5 21 | - The npm version used to install the packages is v6.14.11 22 | 23 | ### Installation 24 | - Clone this project 25 | - Run **npm run install** in the projects folder 26 | - Configure your environment variables in **Netlify** (see below how) 27 | - Push your project to **Netlify** or run **netlify dev --live** (note: you must install netlify-cli before, see how [here.](https://docs.netlify.com/cli/get-started/) 28 | 29 | ### Configuring Netlify Environment Variables 30 | The good thing about Netlify is that we can use our environment variables in production and local. You must configure the following variables to work with this project without problems when running netlify dev --live: 31 | 32 | Environment Variable | Description | Example 33 | --- | --- | --- 34 | REDIS_HOST | Your redis **host** (upstash or other alternative) | 127.0.0.1 35 | REDIS_PORT | Your redis **port** (upstash or other alternative) | 66587 36 | REDIS_PASSWORD | Your redis **password** (upstash or other alternative) | 127.0.0.1 37 | BASE_URL | Your netlify base URL (production or development) | https://serverless-url-shortener-upstash.netlify.app/ 38 | RECAPTCHA_PRIVATE_KEY | Your google private recaptcha key | 39 | 40 | All those variables are mandatory and the system won't work without them. They are accessed via proccess.env. 41 | 42 | ### Good to go? 43 | 1. Create account in service providers 44 | 2. Setup environment variables 45 | 3. Install the project 46 | 4. Install netlify-cli 47 | 5. Install dependencies with npm install 48 | 6. Run the project with **netlify dev --live** 49 | 7. Try it free. 50 | 51 | If you want to talk, you get find me here: msfbr.00@gmail.com -------------------------------------------------------------------------------- /functions/details.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Serverless Function Requirements 3 | */ 4 | const redis = require('redis'); 5 | const asyncRedis = require("async-redis"); 6 | const redisClient = asyncRedis.createClient({ 7 | host : process.env.REDIS_HOST, 8 | port : process.env.REDIS_PORT, 9 | password: process.env.REDIS_PASSWORD 10 | }); 11 | 12 | exports.handler = async function(event, context) { 13 | if(event.httpMethod != 'GET') { 14 | return { 15 | statusCode: 200, 16 | body: JSON.stringify({ 17 | "success": false, 18 | "message": "Invalid method." 19 | }) 20 | }; 21 | } 22 | 23 | const splitUrlPathAndGetLast = event.path.substring(1).split('/').pop(); 24 | const getRedirectUrlVisits = await redisClient.get(`url_visits_${splitUrlPathAndGetLast}`); 25 | 26 | if(getRedirectUrlVisits == null) { 27 | return { 28 | statusCode: 200, 29 | body: JSON.stringify({ 30 | "success": false, 31 | "message": "Invalid code." 32 | }) 33 | }; 34 | } 35 | 36 | return { 37 | statusCode: 200, 38 | body: JSON.stringify({ 39 | "success": true, 40 | "message": "Visits retrieved.", 41 | "data" : { 42 | "visitsCount": parseInt((!getRedirectUrlVisits) ? 0:getRedirectUrlVisits) 43 | } 44 | }) 45 | }; 46 | } -------------------------------------------------------------------------------- /functions/go.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Serverless Function Requirements 3 | */ 4 | const redis = require('redis'); 5 | const asyncRedis = require("async-redis"); 6 | const redisClient = asyncRedis.createClient({ 7 | host : process.env.REDIS_HOST, 8 | port : process.env.REDIS_PORT, 9 | password: process.env.REDIS_PASSWORD 10 | }); 11 | 12 | exports.handler = async function(event, context) { 13 | if(event.httpMethod != 'GET') { 14 | return { 15 | statusCode: 200, 16 | body: JSON.stringify({ 17 | "success": false, 18 | "message": "Invalid method." 19 | }) 20 | }; 21 | } 22 | 23 | const splitUrlPathAndGetLast = event.path.substring(1).split('/').pop(); 24 | const getRedirectUrl = await redisClient.get(`url_${splitUrlPathAndGetLast}`); 25 | const incrementRedirectUrlVisits = await redisClient.incr(`url_visits_${splitUrlPathAndGetLast}`); 26 | 27 | return { 28 | statusCode: 301, 29 | headers: { 30 | Location: getRedirectUrl 31 | }, 32 | body: '' 33 | }; 34 | } -------------------------------------------------------------------------------- /functions/short-url.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Serverless Function Requirements 3 | */ 4 | const fetch = require("node-fetch"); 5 | const redis = require('redis'); 6 | const asyncRedis = require("async-redis"); 7 | const redisClient = asyncRedis.createClient({ 8 | host : process.env.REDIS_HOST, 9 | port : process.env.REDIS_PORT, 10 | password: process.env.REDIS_PASSWORD 11 | }); 12 | 13 | /** 14 | * Shorter App Configurations 15 | */ 16 | const shortUrlSize = 8; 17 | const recaptchaPrivateKey = process.env.RECAPTCHA_PRIVATE_KEY; 18 | 19 | /** 20 | * Creates a random string. 21 | * 22 | * @source https://stackoverflow.com/questions/1349404/generate-random-string-characters-in-javascript 23 | * @param {Number} length 24 | * @returns 25 | */ 26 | function makeid(length) { 27 | var result = ''; 28 | var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 29 | var charactersLength = characters.length; 30 | 31 | for ( var i = 0; i < length; i++ ) { 32 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 33 | } 34 | 35 | return result; 36 | } 37 | 38 | /** 39 | * Generates unique random string for a given URL. 40 | * 41 | * @param {Number} String length 42 | * @param {String} Destination url (where the user will be redirected) 43 | * @returns {Object} 44 | */ 45 | async function generateUniqueShorterUrl(length, destinationUrl) { 46 | let uniqueString = makeid(length); 47 | let shorterUrlExists = await redisClient.get(uniqueString); 48 | 49 | if(shorterUrlExists) { 50 | return generateUniqueShorterUrl(length); 51 | } 52 | 53 | let saveUrl = await redisClient.set(`url_${uniqueString}`, destinationUrl); 54 | let incrementSavedUrlVisits = await redisClient.incr(`url_visits_${uniqueString}`); 55 | let totalSavedUrls = await redisClient.incr('total_saved_urls'); 56 | 57 | return { 58 | "success": (saveUrl == "OK") ? true:false, 59 | "shorterUrl": `${process.env.BASE_URL}/r/${uniqueString}`, 60 | "destinationUrl": destinationUrl, 61 | } 62 | } 63 | 64 | /** 65 | * Verify if the given recaptcha token is valid. 66 | * 67 | * @param {String} Token generated from the front-end. 68 | * @returns Boolean 69 | */ 70 | async function validateRecaptchaToken(token) { 71 | const validateRecaptchaRequest = await fetch('https://www.google.com/recaptcha/api/siteverify', { 72 | method: 'POST', 73 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 74 | body: `secret=${recaptchaPrivateKey}&response=${token}` 75 | }); 76 | 77 | const recaptchaResponse = await validateRecaptchaRequest.json(); 78 | return recaptchaResponse; 79 | } 80 | 81 | exports.handler = async function(event, context) { 82 | if(event.httpMethod != 'POST') { 83 | return { 84 | statusCode: 200, 85 | body: JSON.stringify({ 86 | "success": false, 87 | "message": "Invalid method." 88 | }) 89 | } 90 | } 91 | 92 | const request = JSON.parse(event.body); 93 | const validateRequestWithRecaptcha = await validateRecaptchaToken(request.token); 94 | 95 | if(!validateRequestWithRecaptcha.success) { 96 | return { 97 | statusCode: 200, 98 | body: JSON.stringify({ 99 | "success": false, 100 | "message": "Oops. Try again. Recaptcha validation failed.", 101 | }) 102 | } 103 | } 104 | 105 | const generateShorterUrl = await generateUniqueShorterUrl(shortUrlSize, request.url); 106 | 107 | return { 108 | statusCode: 200, 109 | body: JSON.stringify(generateShorterUrl) 110 | }; 111 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | functions = "functions" 3 | 4 | [dev] 5 | functions = "functions" 6 | 7 | [[redirects]] 8 | from = "/app/short-url" 9 | to = "/.netlify/functions/short-url" 10 | status = 200 11 | 12 | [[redirects]] 13 | from = "/r/*" 14 | to = "/.netlify/functions/go/:splat" 15 | status = 200 16 | 17 | [[redirects]] 18 | from = "/v/*" 19 | to = "/.netlify/functions/details/:splat" 20 | status = 200 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "scripts": { 4 | "dev": "parcel src/index.html", 5 | "build": "parcel build src/index.html" 6 | }, 7 | "devDependencies": { 8 | "@fullhuman/postcss-purgecss": "^4.0.2", 9 | "autoprefixer": "^9", 10 | "parcel-bundler": "^1.12.4", 11 | "postcss": "^7", 12 | "redis": "^3.0.2", 13 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.4" 14 | }, 15 | "dependencies": { 16 | "async-redis": "^1.1.7", 17 | "node-fetch": "^2.6.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const purgecss = require('@fullhuman/postcss-purgecss')({ 2 | // Specify the paths to all of the template files in your project 3 | content: [ 4 | './src/**/*.html', 5 | './src/**/*.vue', 6 | './src/**/*.jsx', 7 | // etc. 8 | ], 9 | 10 | // Include any special characters you're using in this regular expression 11 | defaultExtractor: (content) => content.match(/[\w-/:]+(? 2 | 3 | 4 | 5 | 6 | 7 | 8 | URL Short 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 35 | 36 | 37 | 38 |
39 |

Create shorter links for your urls.

40 |
41 | 42 | 47 |
48 | 49 |
50 |

Check visits count

51 |

Paste the URL code above and see how many visits you got in your link.

52 | 53 |
54 | 55 | 56 | 59 |
60 |
61 |
62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Client-side JavaScript for our Serverless App 3 | * 4 | * @author Matheus Freitas 5 | * @email msfbr.00@gmail.com 6 | */ 7 | ((vanillaJS) => { 8 | // Please, use your recaptcha site key 9 | let googleRecaptchaSiteKey = '6Lf5BIoaAAAAAIGANnsvRam_b9dVwki8Sbub_hj6'; 10 | let shorterUrl = document.querySelector('#shortened-url'); 11 | 12 | // Selectors 13 | let buttonShortUrl = document.querySelector('#button-short-url'); 14 | let inputShortUrl = document.querySelector('#input-short-url'); 15 | let modalConfirmationNotARobot = document.querySelectorAll('.modal-confirmation-group'); 16 | let buttonNotARobot = document.querySelector('#button-not-a-robot'); 17 | let pageOverlay = document.querySelector('#overlay'); 18 | let whenSuccessfullyShortedElements = document.querySelectorAll('.when-successfully-shorted'); 19 | let whenFailedShortedElements = document.querySelectorAll('.when-shorted-failed'); 20 | let inputCopyShortenedUrl = document.querySelector('#shortened-url-input'); 21 | let buttonCopy = document.querySelector('#button-copy'); 22 | let visitsResultHolder = document.querySelector('#visits-result-holder'); 23 | let visitsResult = document.querySelector('#visits-result'); 24 | let buttonGetVisits = document.querySelector('#getVisitsButton'); 25 | let inputShortenedUrlVisitCount = document.querySelector('#shortened-url-visit-result'); 26 | 27 | /** 28 | * Open the confirmation modal before short an URL so 29 | * we avoid bots and flood. 30 | */ 31 | function openConfirmationModal() { 32 | if(!inputShortUrl.value.length) { 33 | return displayError('Fill the URL before continue.'); 34 | } 35 | 36 | modalConfirmationNotARobot.forEach((item) => { 37 | item.classList.remove("hidden"); 38 | item.classList.add("opacity-100"); 39 | }); 40 | } 41 | 42 | /** 43 | * Closes the confirmation modal. 44 | */ 45 | function closeConfirmationModal() { 46 | modalConfirmationNotARobot.forEach((item) => { 47 | item.classList.add("hidden"); 48 | item.classList.remove("opacity-100"); 49 | }); 50 | 51 | whenSuccessfullyShortedElements.forEach((item) => { 52 | item.classList.add('hidden'); 53 | }); 54 | 55 | buttonNotARobot.classList.remove('hidden'); 56 | } 57 | 58 | /** 59 | * Confirms that the user isn't a bot using Google reCaptcha Api. 60 | */ 61 | function confirmImNotARobot() { 62 | buttonNotARobot.innerHTML = ` 63 | 64 | 65 | 66 | `; 67 | 68 | grecaptcha.ready(function() { 69 | grecaptcha.execute(googleRecaptchaSiteKey, { action: 'submit' }).then(function(token) { 70 | fetch('/app/short-url', { 71 | method: 'POST', 72 | body: JSON.stringify({ 73 | url: inputShortUrl.value, 74 | token: token 75 | }) 76 | }).then((response) => response.json()).then((data) => { 77 | return updateUIWithShorterInformation(data); 78 | }); 79 | }); 80 | }); 81 | } 82 | 83 | /** 84 | * Display an error for the user. Right now it'll use alert 85 | * but in the next versions it'll become a custom alert. 86 | * 87 | * @param {String} Message to be displayed 88 | * @return Void 89 | */ 90 | function displayError(message) { 91 | return alert(message); 92 | } 93 | 94 | /** 95 | * Updates the UI with the shorter URL information. 96 | * 97 | * @param {Object} Information about the url 98 | * @return void 99 | */ 100 | function updateUIWithShorterInformation(data) { 101 | if(data.success) { 102 | buttonNotARobot.classList.add('hidden'); 103 | buttonNotARobot.innerHTML = 'I\'m not a robot'; 104 | 105 | whenSuccessfullyShortedElements.forEach((item) => { 106 | item.classList.remove('hidden'); 107 | }); 108 | } 109 | 110 | shorterUrl.innerHTML = data.shorterUrl; 111 | inputCopyShortenedUrl.value = data.shorterUrl; 112 | } 113 | 114 | /** 115 | * Execute a copy in the input field generated by the response. 116 | */ 117 | function copyShortenedUrl() { 118 | inputCopyShortenedUrl.select(); 119 | document.execCommand("copy"); 120 | } 121 | 122 | /** 123 | * Validate the content of the inputed url to retrieve analytics. 124 | * 125 | * @returns Boolean 126 | */ 127 | function validateInputedShortenedUrl(shortenedUrl) { 128 | if(!shortenedUrl.length) { 129 | return false; 130 | } 131 | 132 | if(shortenedUrl.includes('/') || shortenedUrl.includes('.')) { 133 | return false; 134 | } 135 | 136 | return true; 137 | } 138 | 139 | /** 140 | * Search the API and retrieve the visit count of a determined url. 141 | */ 142 | function getUrlVisitsCount() { 143 | const urlToBeRetrieved = inputShortenedUrlVisitCount.value; 144 | 145 | if(!validateInputedShortenedUrl(urlToBeRetrieved)) { 146 | return displayError('Fill a valid value before continue (no full links are allowed).'); 147 | } 148 | 149 | buttonGetVisits.setAttribute('disabled', 'disabled'); 150 | 151 | let request = fetch(`/v/${urlToBeRetrieved}`).then((response) => response.json()).then((response) => { 152 | if(!response.success) { 153 | return displayError('No shortened urls was found.'); 154 | } 155 | 156 | visitsResult.innerHTML = response.data.visitsCount; 157 | visitsResultHolder.classList.remove('hidden'); 158 | buttonGetVisits.removeAttribute('disabled'); 159 | }); 160 | } 161 | 162 | /** 163 | * Event triggers 164 | */ 165 | buttonShortUrl.addEventListener('click', openConfirmationModal); 166 | buttonNotARobot.addEventListener('click', confirmImNotARobot); 167 | pageOverlay.addEventListener('click', closeConfirmationModal); 168 | pageOverlay.addEventListener('click', closeConfirmationModal); 169 | buttonCopy.addEventListener('click', copyShortenedUrl); 170 | buttonGetVisits.addEventListener('click', getUrlVisitsCount); 171 | 172 | })('Vanilla.js Rocks!'); -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./src/**/*.html'], 3 | theme: {}, 4 | variants: {}, 5 | plugins: [], 6 | }; 7 | --------------------------------------------------------------------------------