├── .eslintrc.json ├── .gitignore ├── README.md ├── db ├── add.js ├── find.js ├── hash.js ├── id.js └── index.js ├── generateMarkup.js ├── index.js ├── index.pug ├── package.json ├── revalidate_database.js ├── url ├── index.js ├── parse.js └── validate.js └── web ├── app.js ├── fonts └── TeX-Gyre-Cursor.otf └── main.css /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2017 4 | }, 5 | "env": { 6 | "browser": true, 7 | "commonjs": true, 8 | "es6": true, 9 | "node": true 10 | }, 11 | "extends": "eslint:recommended", 12 | "rules": { 13 | "indent": [ "error", "tab" ], 14 | "linebreak-style": [ "error", "unix" ], 15 | "quotes": [ "error", "single" ], 16 | "semi": [ "error", "always" ], 17 | "no-console": "off", 18 | "for-direction": "error", 19 | "no-await-in-loop": "error", 20 | "no-extra-parens": "error", 21 | "no-template-curly-in-string": "error", 22 | "valid-jsdoc": "error", 23 | "accessor-pairs": "error", 24 | "array-callback-return": "error", 25 | "block-scoped-var": "error", 26 | "class-methods-use-this": "error", 27 | "consistent-return": "error", 28 | "curly": [ "error", "multi", "consistent" ], 29 | "default-case": "error", 30 | "dot-location": [ "error", "property" ], 31 | "dot-notation": "error", 32 | "eqeqeq": "error", 33 | "no-alert": "error", 34 | "no-caller": "error", 35 | "no-else-return": "error", 36 | "no-empty-function": "error", 37 | "no-eq-null": "error", 38 | "no-eval": "error", 39 | "no-extend-native": "error", 40 | "no-extra-bind": "error", 41 | "no-extra-label": "error", 42 | "no-implied-eval": "error", 43 | "no-invalid-this": "error", 44 | "no-iterator": "error", 45 | "no-lone-blocks": "error", 46 | "no-loop-func": "error", 47 | "no-magic-numbers": [ "off", { 48 | "ignoreArrayIndexes": true 49 | } ], 50 | "no-multi-spaces": "error", 51 | "no-multi-str": "error", 52 | "no-new": "error", 53 | "no-new-func": "error", 54 | "no-new-wrappers": "error", 55 | "no-octal-escape": "error", 56 | "no-proto": "error", 57 | "no-return-await": "error", 58 | "no-script-url": "error", 59 | "no-self-compare": "error", 60 | "no-throw-literal": "error", 61 | "no-unmodified-loop-condition": "error", 62 | "no-unused-expressions": [ "error", { 63 | "allowShortCircuit": true, 64 | "allowTernary": true, 65 | "allowTaggedTemplates": true 66 | } ], 67 | "no-useless-call": "error", 68 | "no-useless-concat": "error", 69 | "no-useless-return": "error", 70 | "no-void": "error", 71 | "no-with": "error", 72 | "prefer-promise-reject-errors": [ 73 | "error", 74 | { "allowEmptyReject": true } 75 | ], 76 | "require-await": "error", 77 | "vars-on-top": "error", 78 | "strict": [ "error", "global" ], 79 | "no-catch-shadow": "error", 80 | "no-label-var": "error", 81 | "no-shadow": "error", 82 | "no-shadow-restricted-names": "error", 83 | "no-undef-init": "error", 84 | "no-undefined": "error", 85 | "no-use-before-define": "error", 86 | "global-require": "error", 87 | "handle-callback-err": "error", 88 | "no-buffer-constructor": "error", 89 | "no-mixed-requires": "error", 90 | "no-new-require": "error", 91 | "no-path-concat": "error", 92 | "no-process-exit": "error", 93 | "array-bracket-spacing": [ "error", "always" ], 94 | "block-spacing": "error", 95 | "brace-style": "error", 96 | "camelcase": [ "error", { "properties": "never" } ], 97 | "comma-dangle": "error", 98 | "comma-spacing": "error", 99 | "comma-style": "error", 100 | "computed-property-spacing": "error", 101 | "consistent-this": [ "error", "self" ], 102 | "eol-last": "error", 103 | "func-call-spacing": "error", 104 | "func-name-matching": "error", 105 | "func-names": [ "error", "as-needed" ], 106 | "func-style": [ 107 | "error", "declaration", 108 | { "allowArrowFunctions": true } 109 | ], 110 | "key-spacing": "error", 111 | "keyword-spacing": "error", 112 | "lines-around-comment": "error", 113 | "max-len": "error", 114 | "max-nested-callbacks": "error", 115 | "max-params": "error", 116 | "max-statements": "off", 117 | "max-statements-per-line": "error", 118 | "new-cap": [ "error", { "capIsNewExceptionPattern": "Error$" } ], 119 | "new-parens": "error", 120 | "no-array-constructor": "error", 121 | "no-continue": "error", 122 | "no-lonely-if": "error", 123 | "no-multiple-empty-lines": "error", 124 | "no-negated-condition": "error", 125 | "no-new-object": "error", 126 | "no-trailing-spaces": "error", 127 | "no-underscore-dangle": "off", 128 | "no-unneeded-ternary": "error", 129 | "no-whitespace-before-property": "error", 130 | "object-curly-spacing": [ "error", "always" ], 131 | "object-property-newline": [ "error", { 132 | "allowMultiplePropertiesPerLine": true 133 | } ], 134 | "operator-assignment": "error", 135 | "operator-linebreak": "error", 136 | "quote-props": [ "error", "as-needed", { 137 | "keywords": false, 138 | "numbers": false 139 | } ], 140 | "require-jsdoc": [ "off", { "require": { 141 | "MethodDefinition": true, 142 | "ClassDeclaration": true 143 | } } ], 144 | "semi-spacing": "error", 145 | "semi-style": "error", 146 | "sort-keys": [ "error", "asc", { 147 | "caseSensitive": false, 148 | "natural": true 149 | } ], 150 | "sort-vars": [ "error", { "ignoreCase": true } ], 151 | "space-before-blocks": "error", 152 | "space-before-function-paren": [ "error", { "named": "never" } ], 153 | "space-in-parens": "error", 154 | "space-infix-ops": "error", 155 | "space-unary-ops": [ "error", { "words": true, "nonwords": false } ], 156 | "spaced-comment": "error", 157 | "switch-colon-spacing": "error", 158 | "template-tag-spacing": "error", 159 | "unicode-bom": "error", 160 | "arrow-body-style": "error", 161 | "arrow-spacing": "error", 162 | "generator-star-spacing": "error", 163 | "no-duplicate-imports": [ "error", { "includeExports": true } ], 164 | "no-useless-computed-key": "error", 165 | "no-useless-constructor": "error", 166 | "no-useless-rename": "error", 167 | "no-var": "warn", 168 | "object-shorthand": "error", 169 | "prefer-arrow-callback": [ "error", { "allowNamedFunctions": true } ], 170 | "prefer-const": "error", 171 | "prefer-destructuring": "error", 172 | "prefer-numeric-literals": "error", 173 | "prefer-rest-params": "warn", 174 | "prefer-spread": "warn", 175 | "rest-spread-spacing": "error", 176 | "sort-imports": [ "error", { "ignoreCase": true } ], 177 | "symbol-description": "error", 178 | "template-curly-spacing": "error" 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | package-lock.json 3 | links.db 4 | web/*.html 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | URL Shortener 2 | ============= 3 | 4 | Hello there! 5 | 6 | This is TheDevs' simple URL shortener! 7 | 8 | Information 9 | ------------ 10 | 11 | POST to this URL with a URL of your choice as a request body to create a shortened link. 12 | 13 | The server will respond with the ID. 14 | 15 | Navigating to https://devs.sh/ID with the given ID will redirect you to the link provided. 16 | 17 | Code examples 18 | ------------- 19 | 20 | ### cURL 21 | 22 | curl -d "https://example.com/" https://devs.sh/ 23 | D 24 | 25 | ### Clientside JavaScript 26 | 27 | fetch('https://devs.sh/', { 28 | body: 'https://example.com/', 29 | method: 'POST' 30 | }) 31 | .then(res => 32 | res.text()) 33 | .then(id => 34 | console.log(id)); 35 | 36 | ### Node.js 37 | 38 | axios.post('https://devs.sh/', 39 | "https://example.com/") 40 | .then(id => 41 | console.log(id)); 42 | 43 | Shortening "https://example.com/" your link will be: https://devs.sh/D 44 | -------------------------------------------------------------------------------- /db/add.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createAdder = ({ 4 | createID, 5 | db, 6 | hash 7 | }) => url => 8 | db.findOne({ url }).then(doc => 9 | doc || createID(hash(url)) 10 | .then(_id => _id 11 | ? db.insert({ 12 | _id, 13 | created: new Date(), 14 | url }) 15 | : null)) 16 | .then(doc => doc && doc._id); 17 | 18 | module.exports = createAdder; 19 | -------------------------------------------------------------------------------- /db/find.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createFinder = ({ db }) => _id => 4 | db.findOne({ _id }) 5 | .then(doc => doc && doc.url); 6 | 7 | module.exports = createFinder; 8 | -------------------------------------------------------------------------------- /db/hash.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { createHash } = require('crypto'); 4 | 5 | const hash = str => 6 | createHash('sha256') 7 | .update(str) 8 | .digest('base64') 9 | .replace(/\+/g, '-') 10 | .replace(/\//g, '_') 11 | .replace(/=/g, ''); 12 | 13 | module.exports = hash; 14 | -------------------------------------------------------------------------------- /db/id.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createID = ({ 4 | db, 5 | hash, 6 | logger, 7 | minLength 8 | }) => 9 | function generate(str, len = minLength) { 10 | const _id = str.substr(0, len); 11 | return len > str.length 12 | ? (logger.log('hash collision at', str, 'rehashing...'), 13 | generate(hash(str))) 14 | : db.findOne({ _id }).then(doc => doc 15 | ? generate(str, len + 1) 16 | : _id); 17 | }; 18 | 19 | module.exports = createID; 20 | -------------------------------------------------------------------------------- /db/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const MINIMUM_ID_LENGTH = 1; 4 | 5 | const { join } = require('path'); 6 | 7 | // Snippet grabbed from: https://github.com/thedevs-network/the-guard-bot/blob/e951497c7122c642037084249cf18066fec0a3de/index.js#L4-L11 8 | { 9 | // NeDB on life support 10 | // some util methods are removed in node 23.x, monkeypatch them 11 | const util = require('util'); 12 | const patch_methods = [ 'isDate', 'isRegExp' ]; 13 | for (let i = 0; i < patch_methods.length; i++) { 14 | util[patch_methods[i]] = util.types[patch_methods[i]]; 15 | } 16 | util.isArray = Array.isArray; 17 | } 18 | 19 | const Datastore = require('nedb-promise'); 20 | 21 | const createAdder = require('./add'); 22 | const createFinder = require('./find'); 23 | const createIDGenerator = require('./id'); 24 | const hash = require('./hash'); 25 | 26 | const absolute = path => join(__dirname, path); 27 | 28 | const db = new Datastore({ 29 | autoload: true, 30 | filename: absolute('links.db') 31 | }); 32 | 33 | db.ensureIndex({ 34 | fieldName: 'url', 35 | unique: true 36 | }); 37 | 38 | module.exports = Object.freeze({ 39 | add: createAdder({ 40 | createID: createIDGenerator({ 41 | db, 42 | hash, 43 | logger: console, 44 | minLength: MINIMUM_ID_LENGTH 45 | }), 46 | db, 47 | hash 48 | }), 49 | find: createFinder({ db }) 50 | }); 51 | -------------------------------------------------------------------------------- /generateMarkup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { join } = require('path'); 4 | const { readFileSync, writeFileSync } = require('fs'); 5 | 6 | const { compile } = require('pug'); 7 | const { Parser, HtmlRenderer } = require('commonmark'); 8 | 9 | const absolute = path => join(__dirname, path); 10 | 11 | const markup = new HtmlRenderer().render( 12 | new Parser().parse( 13 | readFileSync(absolute('README.md'), 'utf8'))); 14 | 15 | writeFileSync(absolute('web/index.html'), 16 | compile(readFileSync(absolute('index.pug'), 'utf8'), { 17 | filters: { 18 | commonmark() { 19 | return markup; 20 | } 21 | } 22 | })()); 23 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { join } = require('path'); 4 | 5 | const express = require('express'); 6 | const bodyParser = require('body-parser'); 7 | 8 | const abs = path => join(__dirname, path); 9 | 10 | require('./generateMarkup'); 11 | 12 | const { validate, parse } = require('./url'); 13 | const { find, add } = require('./db'); 14 | 15 | const app = express(); 16 | 17 | app.use(bodyParser.text({ type: '*/*' })); 18 | 19 | app.use(express.static(abs('web'))); 20 | 21 | app.get('/:id', (req, res) => 22 | find(req.params.id).then(url => url 23 | ? res.redirect(url) 24 | : res.sendStatus(404))); 25 | 26 | app.post('/', (req, res) => (url => 27 | validate(url) 28 | ? add(parse(url)).then(id => id 29 | ? (res.set('Access-Control-Allow-Origin', '*'), 30 | res.send(id)) 31 | : res.status(500).send('Server Error')) 32 | : res.status(400).send('Invalid URL'))(String(req.body))); 33 | 34 | app.listen(4000, '127.0.0.1', (err) => err 35 | ? console.error(err) 36 | : console.log('Server listening on port 4000')); 37 | -------------------------------------------------------------------------------- /index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | meta(charset='UTF-8')/ 5 | meta(name='viewport' content='width=device-width, initial-scale=1, user-scalable=no')/ 6 | title URL shortener 7 | link(rel='stylesheet' type='text/css' href='/main.css')/ 8 | body 9 | div(class='container') 10 | div(class='inputContainer') 11 | input(type='text' id='input_url' placeholder='https://example.com/') 12 | button(id='submit_button' disabled) Shorten 13 | div(id='result') 14 | article 15 | :commonmark 16 | script(type='application/javascript' src='/app.js') 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "url-shortener", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/TheDevs-Network/url-shortener.git" 12 | }, 13 | "keywords": [ 14 | "url", 15 | "shortener" 16 | ], 17 | "author": "Thomas Rory Gummerson (https://trgwii.no)", 18 | "license": "Beerware", 19 | "bugs": { 20 | "url": "https://github.com/TheDevs-Network/url-shortener/issues" 21 | }, 22 | "homepage": "https://github.com/TheDevs-Network/url-shortener#readme", 23 | "dependencies": { 24 | "body-parser": "^1.17.2", 25 | "commonmark": "^0.27.0", 26 | "express": "^4.15.3", 27 | "nedb-promise": "^2.0.1", 28 | "pug": "^2.0.0-rc.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /revalidate_database.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { join } = require('path'); 4 | 5 | const Datastore = require('nedb-promise'); 6 | 7 | const validate = require('./url/validate'); 8 | 9 | const absolute = path => join(__dirname, path); 10 | 11 | const db = new Datastore({ 12 | autoload: true, 13 | filename: absolute(join('db', 'links.db')) 14 | }); 15 | 16 | db.find({}).then(docs => Promise.all(docs.map(doc => { 17 | if (!validate(doc.url)) { 18 | console.log('Removing ' + doc._id + ': ' + doc.url); 19 | return db.remove({ _id: doc._id }); 20 | } 21 | return Promise.resolve(); 22 | }))) 23 | .then(() => console.log('Done!')); 24 | 25 | -------------------------------------------------------------------------------- /url/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parse = require('./parse'); 4 | const validate = require('./validate'); 5 | 6 | module.exports = Object.freeze({ 7 | parse, 8 | validate 9 | }); 10 | -------------------------------------------------------------------------------- /url/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { URL } = require('url'); 4 | 5 | const parse = url => 6 | new URL(url).href; 7 | 8 | module.exports = parse; 9 | -------------------------------------------------------------------------------- /url/validate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { URL } = require('url'); 4 | 5 | const banned = [ 6 | 'freebitco.in', 7 | 'freedoge.co.in', 8 | 'sereyoudom.com.kh', 9 | 'trkxc.com', 10 | 'uetrk.com' 11 | ]; 12 | 13 | const validate = urlString => { 14 | try { 15 | const url = new URL(urlString); 16 | if (banned.find(domain => url.hostname.endsWith(domain))) 17 | return false; 18 | return true; 19 | } catch (err) { 20 | return false; 21 | } 22 | }; 23 | 24 | module.exports = validate; 25 | -------------------------------------------------------------------------------- /web/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const input = document.getElementById('input_url'); 4 | const submit = document.getElementById('submit_button'); 5 | const result = document.getElementById('result'); 6 | 7 | const copy = (elem => str => 8 | (original => (elem.value = str, 9 | elem.select(), 10 | document.execCommand('copy'), 11 | elem.value = original))(elem.value))(input); 12 | 13 | const shorten = url => fetch('/', { 14 | body: url, 15 | method: 'POST' 16 | }).then(res => res.text()).then(val => { 17 | if ([ 18 | 'Invalid URL', 19 | 'Server Error' 20 | ].includes(val)) 21 | throw Error(val); 22 | return val; 23 | }); 24 | 25 | const validate = url => { 26 | try { 27 | url = new URL(url).href; 28 | return { ok: true, url }; 29 | } catch (err) { 30 | return { error: err, ok: false }; 31 | } 32 | }; 33 | 34 | const dom = { 35 | clear: () => 36 | (input.value = '', 37 | result.textContent = '', 38 | result.style.color = '', 39 | submit.textContent = 'Shorten', 40 | submit.disabled = true), 41 | error: (err) => 42 | (result.textContent = err.name + ': ' + err.message, 43 | result.style.color = 'red', 44 | submit.textContent = 'Shorten'), 45 | unerror: () => 46 | (result.textContent = '', 47 | result.style.color = ''), 48 | result: (str) => 49 | (result.textContent = str, 50 | result.style.color = '', 51 | submit.textContent = 'Copy') 52 | }; 53 | 54 | input.addEventListener('input', () => 55 | (valid => (submit.disabled = !valid.ok, 56 | submit.textContent = 'Shorten', 57 | valid.ok 58 | ? dom.unerror() 59 | : dom.error(valid.error)))(validate(input.value))); 60 | 61 | submit.addEventListener('click', () => 62 | submit.textContent === 'Shorten' 63 | ? shorten(input.value) 64 | .then(id => dom.result(location.href + id)) 65 | .catch(dom.error) 66 | : (copy(result.textContent), 67 | dom.clear())); 68 | -------------------------------------------------------------------------------- /web/fonts/TeX-Gyre-Cursor.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/url-shortener/11cd632799cd762716c81c7234fe160682b18535/web/fonts/TeX-Gyre-Cursor.otf -------------------------------------------------------------------------------- /web/main.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'TeX-Gyre-Cursor'; 3 | src: url('/fonts/TeX-Gyre-Cursor.otf'); 4 | } 5 | 6 | html, body { 7 | height: 100%; 8 | margin: 0; 9 | } 10 | 11 | body, input, button, p { 12 | background-color: #151515; 13 | color: white; 14 | font-family: 'TeX-Gyre-Cursor'; 15 | } 16 | 17 | textarea, input, button { outline: none; } 18 | 19 | .container { 20 | width: 1200px; 21 | margin: 0 auto; 22 | padding: 30px 0; 23 | } 24 | 25 | pre { 26 | color: gray; 27 | padding-left: 36px; 28 | overflow-x: auto; 29 | } 30 | 31 | input[type='text'] { 32 | background: transparent; 33 | border: none; 34 | border-bottom: 2px solid #e3e3e3; 35 | line-height: 30px; 36 | border-radius: 2px; 37 | color: #e3e3e3; 38 | padding: 5px; 39 | font-size: 16px; 40 | margin-right: 10px; 41 | } 42 | 43 | button { 44 | background: transparent; 45 | line-height: 30px; 46 | padding: 5px 10px; 47 | border: 2px solid #e3e3e3; 48 | cursor: pointer; 49 | margin-left: 5px; 50 | margin-right: 5px; 51 | -webkit-transition: all 200ms ease-in-out; 52 | -moz-transition: all 200ms ease-in-out; 53 | -ms-transition: all 200ms ease-in-out; 54 | -o-transition: all 200ms ease-in-out; 55 | transition: all 200ms ease-in-out; 56 | min-width: 100px; 57 | } 58 | 59 | button:hover { 60 | color: #151515; 61 | background: #e3e3e3; 62 | } 63 | 64 | button#submit_button:disabled { 65 | color: gray; 66 | border-color: gray; 67 | } 68 | 69 | button#submit_button:disabled:hover { 70 | color: gray; 71 | background: #151515; 72 | } 73 | 74 | #show_url { 75 | display: none; 76 | position: fixed; 77 | top: 0; 78 | left: 0; 79 | width: 100%; 80 | height: 100%; 81 | background: rgba(0,0,0, 0.6); 82 | text-align: center; 83 | padding: 200px 0; 84 | } 85 | 86 | #show_url p { 87 | padding: 50px; 88 | max-width: 500px; 89 | background: #e3e3e3; 90 | color: #151515; 91 | text-align: center; 92 | text-align: center; 93 | font-size: 22px; 94 | margin: 50px auto; 95 | } 96 | 97 | .inputContainer { 98 | text-align: center; 99 | padding: 20px 0; 100 | } 101 | 102 | .visible { 103 | display: block !important; 104 | } 105 | 106 | @media all and (max-width: 525px) { 107 | input[type=text] { 108 | margin-bottom: 10px; 109 | } 110 | } 111 | 112 | @media all and (min-width: 961px) and (max-width: 1230px) { 113 | .container { 114 | width: 940px; 115 | } 116 | } 117 | 118 | @media all and (min-width: 769px) and (max-width: 960px) { 119 | .container { 120 | width: 740px; 121 | } 122 | } 123 | 124 | @media all and (min-width: 525px) and (max-width: 768px){ 125 | .container { 126 | width: 500px; 127 | } 128 | } 129 | 130 | @media all and (min-width: 320px) and (max-width: 524px) { 131 | .container { 132 | width: 300px; 133 | } 134 | #show_url { 135 | padding: 70px 0; 136 | } 137 | #show_url p { 138 | max-width: 300px; 139 | padding: 30px 15px; 140 | overflow: auto; 141 | } 142 | } --------------------------------------------------------------------------------