├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── functions └── redirect.js ├── netlify.toml ├── other ├── README.md └── shorten.js ├── package.json └── public └── static ├── robots.txt └── wp-login.php /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .netlify 3 | .DS_Store 4 | .env 5 | .env.* 6 | 7 | # these cause more harm than good 8 | # when working with contributors 9 | package-lock.json 10 | yarn.lock 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | node_modules 3 | dist 4 | coverage 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": false, 9 | "jsxBracketSameLine": false 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # airtable-netlify-short-urls 3 | 4 | :warning: **There's a simpler version using [Netlify redirects](https://www.netlify.com/docs/redirects/) instead of Airtable [here](https://github.com/kentcdodds/netlify-shortener)** 5 | 6 | This is a simple short-url service that works with 7 | [netlify functions](https://www.netlify.com/docs/functions/) and uses 8 | [airtable](https://airtable.com). 9 | 10 | It's recommended to use this with 11 | [CloudFlare caching](https://support.cloudflare.com/hc/en-us/articles/200168326-Are-301-and-302-redirects-cached-by-Cloudflare-) 12 | because airtable has a limit of 5 requests per second. Also, CloudFlare can give 13 | you nice analytics for free. 14 | 15 | ## Usage 16 | 17 | First, setup an airtable account with a base and table. The table should have 18 | a column for the short code and one for the long link. 19 | 20 | Next deploy this github repo to netlify: 21 | 22 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/kentcdodds/airtable-netlify-short-urls) 23 | 24 | Then set the following environment variables in netlify: 25 | 26 | ``` 27 | DEFAULT_REDIRECT_URL -> https://example.com 28 | AIRTABLE_KEY -> ***************** 29 | AIRTABLE_BASE -> ***************** 30 | AIRTABLE_TABLE -> URLs 31 | AIRTABLE_SHORT_CODE_FIELD -> Short Code 32 | AIRTABLE_LONG_LINK_FIELD -> Long Link 33 | ENABLE_CACHE -> false 34 | ``` 35 | 36 | > Note: `AIRTABLE_TABLE`, `AIRTABLE_SHORT_CODE_FIELD`, 37 | > `AIRTABLE_LONG_LINK_FIELD`, and `ENABLE_CACHE` are showing the default values 38 | > above. If that's what you call your table and fields then you don't need to 39 | > set those variables. 40 | 41 | > Note also that you can use a `.env` file instead, just don't commit this to 42 | > source control :) (this is useful for local development as `.env` is in the 43 | > `.gitignore`). 44 | 45 | Redirects should be setup automatically for you in the `netlify.toml`, so you 46 | shouldn't have to do anything there. 47 | 48 | Now go ahead and test that your redirects are working as expected. Just go to 49 | the short URL version of your netlify app and it should redirect you like so: 50 | http://jsair.netlify.com/first -> https://javascriptair.com/episodes/2015-12-09/ 51 | 52 | If that works you're on the right track! 53 | 54 | Next, [set up Netlify with a custom domain](https://www.netlify.com/docs/custom-domains/) 55 | then verify that the redirect works with the custom domain. 56 | 57 | Now, go get CloudFlare setup with your custom domain to prevent your function 58 | from being called more than airtable's rate limiting can handle. 59 | 60 | If you're not using CloudFlare, then set `ENABLE_CACHE` to `true` so you can get 61 | some caching from Netlify. That always seemed to not work very well for me 62 | though (which is one reason I use CloudFlare instead) so good luck. 63 | -------------------------------------------------------------------------------- /functions/redirect.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const fs = require('fs') 3 | 4 | const defaultRedirectURL = getEnv('DEFAULT_REDIRECT_URL') 5 | const cacheBusterCode = getEnv('CACHE_BUSTER_CODE', '_bust-cache') 6 | 7 | // I guess functions exist for a while in memory, so this can help 8 | // us avoid having to call airtable for the same link during that time. 9 | let fakeCache = {} 10 | const bustCache = () => (fakeCache = {}) 11 | 12 | exports.handler = async (event, context) => { 13 | // just something for grouping the netlify logs for this run together 14 | const runId = Date.now() 15 | .toString() 16 | .slice(-5) 17 | const log = (...args) => console.log(runId, ...args) 18 | 19 | const {host = ''} = event.headers 20 | log(`Request coming to "${event.path}"`) 21 | const [, code] = event.path.match(/^.*?redirect\/?(.*)$/) || [event.path, ''] 22 | if (!code) { 23 | log(`no code provided`) 24 | return getResponse({statusCode: 301}) 25 | } 26 | if (code === cacheBusterCode) { 27 | log('busting the cache') 28 | bustCache() 29 | return {statusCode: 200, body: 'cache busted'} 30 | } 31 | const codeLength = code.length 32 | if (codeLength > 50) { 33 | log(`short code "${code}" is ${codeLength} characters long. Seems fishy.`) 34 | return getResponse() 35 | } 36 | if (fakeCache[code]) { 37 | log(`short code "${code}" exists in our in-memory cache.`) 38 | return getResponse({longLink: fakeCache[code], statusCode: 301}) 39 | } 40 | try { 41 | const apiKey = getEnv('AIRTABLE_KEY') 42 | const base = getEnv('AIRTABLE_BASE') 43 | const table = getEnv('AIRTABLE_TABLE', 'URLs') 44 | const shortCodeField = getEnv('AIRTABLE_SHORT_CODE_FIELD', 'Short Code') 45 | const longLinkField = getEnv('AIRTABLE_LONG_LINK_FIELD', 'Long Link') 46 | const Airtable = require('airtable') 47 | log(`Attempting to get long link for code "${code}"`) 48 | const result = await new Airtable({apiKey}) 49 | .base(base)(table) 50 | .select({ 51 | maxRecords: 1, 52 | fields: [longLinkField], 53 | filterByFormula: `{${shortCodeField}} = "${code}"`, 54 | }) 55 | .firstPage() 56 | const longLink = result[0].get(longLinkField) 57 | if (longLink) { 58 | fakeCache[code] = longLink 59 | return getResponse({longLink, statusCode: 301}) 60 | } else { 61 | log(`There was no Long Link associated with "${code}".`) 62 | return getResponse() 63 | } 64 | } catch (error) { 65 | if (error.stack) { 66 | log(error.stack) 67 | } else { 68 | log(error) 69 | } 70 | log('!! there was an error and we are ignoring it... !!') 71 | } 72 | 73 | return getResponse() 74 | 75 | function getResponse({longLink = defaultRedirectURL, statusCode = 302} = {}) { 76 | const title = `${host}/${code || ''}` 77 | log(`> redirecting: ${title} -> ${longLink}`) 78 | const body = `${title}moved here` 79 | 80 | return { 81 | statusCode, 82 | body, 83 | headers: { 84 | Location: longLink, 85 | // this needs to be enabled... but I'm really struggling on how to make 86 | // it work properly... 87 | // 'Cache-Control': 'public, max-age=10080', // 10080 seconds is 1 week 88 | 'Cache-Control': 'no-cache', 89 | // these headers I got by curling a bit.ly URL 90 | // and just doing what they do. 91 | 'Content-Length': String(body.length), 92 | 'Content-Type': 'text/html; charset=utf-8', 93 | Connection: 'close', 94 | 'Content-Security-Policy': 'referrer always', 95 | 'Referrer-Policy': 'unsafe-url', 96 | }, 97 | } 98 | } 99 | } 100 | 101 | function getEnv(name, defaultValue) { 102 | return process.env[name] || defaultValue 103 | } 104 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | functions = ".netlify/functions/" 4 | 5 | [[redirects]] 6 | from = "/" 7 | to = "/.netlify/functions/redirect" 8 | 9 | [[redirects]] 10 | from = "/robots.txt" 11 | to = "/static/robots.txt" 12 | status = 200 13 | force = true 14 | 15 | [[redirects]] 16 | from = "/wp-login.php" 17 | to = "/static/wp-login.php" 18 | status = 200 19 | force = true 20 | 21 | [[redirects]] 22 | from = "/:code" 23 | to = "/.netlify/functions/redirect/:code" 24 | status = 200 25 | force = true 26 | -------------------------------------------------------------------------------- /other/README.md: -------------------------------------------------------------------------------- 1 | # shorten 2 | 3 | This is intended to be used locally. I use it with a bash function I have 4 | defined in my dotfiles: 5 | 6 | ```bash 7 | shorten () 8 | { 9 | ~/code/airtable-netlify-short-urls/other/shorten.js "$1" "$2" 10 | } 11 | ``` 12 | 13 | NOTE: You'll need a `URL_BASE` environment variable in your `.env` for this to 14 | work properly. 15 | -------------------------------------------------------------------------------- /other/shorten.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const {URL} = require('url') 3 | const Airtable = require('airtable') 4 | const {copy} = require('copy-paste') 5 | 6 | require('dotenv').config({path: path.join(__dirname, '../.env')}) 7 | 8 | const apiKey = getEnv('AIRTABLE_KEY') 9 | const base = getEnv('AIRTABLE_BASE') 10 | const table = getEnv('AIRTABLE_TABLE', 'URLs') 11 | const urlBase = getEnv('URL_BASE') 12 | const shortCodeField = getEnv('AIRTABLE_SHORT_CODE_FIELD', 'Short Code') 13 | const longLinkField = getEnv('AIRTABLE_LONG_LINK_FIELD', 'Long Link') 14 | const airtable = new Airtable({apiKey}) 15 | 16 | let [, , longLink, code] = process.argv 17 | code = code || generateCode() 18 | 19 | main() 20 | 21 | async function main() { 22 | if (!longLink) { 23 | console.log('Must provide the full link as an argument') 24 | return 25 | } 26 | try { 27 | // validate URL 28 | new URL(longLink) 29 | } catch (error) { 30 | console.log(`${longLink} is not a valid URL`) 31 | return 32 | } 33 | console.log(`Attempting to set redirect "${code}" -> ${longLink}`) 34 | const existingRecords = await getExistingRecord() 35 | if (existingRecords && existingRecords[0]) { 36 | const existingLink = existingRecords[0].get(longLinkField) 37 | console.log( 38 | `A link with this code already exists. It points to ${existingLink}`, 39 | ) 40 | return 41 | } 42 | const createdRecord = await shorten() 43 | await copyLink(`${urlBase}${createdRecord.fields[shortCodeField]}`) 44 | } 45 | 46 | function getExistingRecord() { 47 | return airtable 48 | .base(base)(table) 49 | .select({ 50 | maxRecords: 1, 51 | fields: [longLinkField], 52 | filterByFormula: `{${shortCodeField}} = "${code}"`, 53 | }) 54 | .firstPage() 55 | } 56 | 57 | function shorten() { 58 | return airtable 59 | .base(base)(table) 60 | .create({ 61 | [shortCodeField]: code, 62 | [longLinkField]: longLink, 63 | }) 64 | } 65 | 66 | function copyLink(link) { 67 | return new Promise(resolve => { 68 | copy(link, () => { 69 | console.log(`${link} has been copied to your clipboard`) 70 | resolve(link) 71 | }) 72 | }) 73 | } 74 | 75 | function generateCode() { 76 | let text = '' 77 | const possible = 78 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 79 | 80 | for (var i = 0; i < 5; i++) { 81 | text += possible.charAt(Math.floor(Math.random() * possible.length)) 82 | } 83 | 84 | return text 85 | } 86 | 87 | function getEnv(name, defaultValue) { 88 | return process.env[name] || defaultValue 89 | } 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airtable-netlify-short-urls", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "URL shortener with an airtable backend", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "netlify-lambda serve functions", 9 | "build": "netlify-lambda build functions" 10 | }, 11 | "keywords": [], 12 | "author": "Kent C. Dodds (http://kentcdodds.com/)", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "copy-paste": "^1.3.0", 16 | "netlify-lambda": "^1.1.0" 17 | }, 18 | "dependencies": { 19 | "airtable": "^0.5.8", 20 | "dotenv": "^6.1.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /public/static/robots.txt: -------------------------------------------------------------------------------- 1 | # Hello robot 2 | User-Agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/static/wp-login.php: -------------------------------------------------------------------------------- 1 | lol 2 | --------------------------------------------------------------------------------