├── .env.sample ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── api ├── database.ts └── main.ts ├── env.ts └── public ├── assets └── logo.svg ├── index.html ├── script.js └── styles.css /.env.sample: -------------------------------------------------------------------------------- 1 | MONGO_URL= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.test.* -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true, 4 | "[python]": { 5 | "editor.defaultFormatter": "ms-python.black-formatter" 6 | }, 7 | "python.formatting.provider": "none" 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aditya 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 | # WebShortener 2 | A simple web shortener made with [oak](https://deno.land/x/oak) and [mongo](https://mongodb.com). 3 | 4 | WebShortener - A simple lightweight link shortener using MongoDB. | Product Hunt 5 | 6 | # Live Version 7 | You can find a live version of this project [here](https://short.xditya.me). 8 | 9 | # API 10 | The API endpoint is `/shorten`, it takes `url` as a query parameter and returns the shortened URL. 11 | Example usage can be found [here](./public/script.js). 12 | 13 | # Deploying 14 | You can easily deploy this project to [deno deploy](https://deno.com/deploy). 15 | 1. Fork the repository (and give it a star too!) 16 | 2. Head on to [deno deploy](https://deno.com/deploy) and login with your GitHub account. 17 | 3. Select "WebShortener" from the list of repositories. 18 | 4. Choose "main" as the branch, and "api/main.ts" as the entrypoint. 19 | 5. Head on to the settings of your app and add your `MONGO_URL` 20 | 6. Vist the deployed app and enjoy! 21 | 22 | # License 23 | This project is licensed under the [MIT License](./LICENSE). 24 | 25 | # Credits 26 | Made with ❤️ by [Aditya](https://xditya.me). 27 | -------------------------------------------------------------------------------- /api/database.ts: -------------------------------------------------------------------------------- 1 | /* 2 | (c) @xditya 3 | View the license: https://github.com/xditya/WebShortener/blob/master/LICENSE 4 | */ 5 | import { 6 | MongoClient, 7 | ObjectId, 8 | } from "https://deno.land/x/mongo@v0.31.2/mod.ts"; 9 | 10 | import config from "../env.ts"; 11 | 12 | console.log("Connecting to MongoDB..."); 13 | const client = new MongoClient(); 14 | const MONGO_URL = new URL(config.MONGO_URL); 15 | if (!MONGO_URL.searchParams.has("authMechanism")) { 16 | MONGO_URL.searchParams.set("authMechanism", "SCRAM-SHA-1"); 17 | } 18 | try { 19 | await client.connect(MONGO_URL.href); 20 | } catch (err) { 21 | console.error("Error connecting to MongoDB", err); 22 | throw err; 23 | } 24 | const db = client.database("SelfShortener"); 25 | 26 | interface UrlSchema { 27 | _id: ObjectId; 28 | hash: string; 29 | url: string; 30 | } 31 | 32 | const urls = db.collection("URLS"); 33 | 34 | export function checkIfUrlExists(url: string) { 35 | return urls.findOne({ url }); 36 | } 37 | 38 | export async function shortenUrl(url: string) { 39 | const isUrlExists = await checkIfUrlExists(url); 40 | if (isUrlExists) { 41 | return isUrlExists.hash; 42 | } 43 | let hash = Math.random().toString(36).substring(2, 7); 44 | while (await urls.findOne({ hash })) { 45 | hash = Math.random().toString(36).substring(2, 7); 46 | } 47 | await urls.insertOne({ url, hash }); 48 | return hash; 49 | } 50 | 51 | export async function getUrl(hash: string) { 52 | return (await urls.findOne({ hash }))?.url; 53 | } 54 | -------------------------------------------------------------------------------- /api/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | (c) @xditya 3 | View the license: https://github.com/xditya/WebShortener/blob/master/LICENSE 4 | */ 5 | 6 | import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts"; 7 | import { getUrl, shortenUrl } from "./database.ts"; 8 | 9 | const router = new Router(); 10 | 11 | router.get("/:urlhash", async (context) => { 12 | if (!context.params.urlhash) { 13 | return context.response.body = { "error": "No URL provided." }; 14 | } 15 | const url = await getUrl(context.params.urlhash); 16 | if (!url) { 17 | return context.response.body = { "error": "Invalid URL." }; 18 | } 19 | context.response.redirect(url); 20 | }); 21 | 22 | router.post("/shorten", async (context) => { 23 | const data = await context.request.body().value; 24 | if (!data || !data.url) { 25 | return context.response.body = { "error": "No URL provided." }; 26 | } 27 | const hash = await shortenUrl(data.url); 28 | context.response.body = { hash }; 29 | }); 30 | 31 | const app = new Application(); 32 | app.use(async (ctx, next) => { 33 | ctx.response.headers.set("Access-Control-Allow-Origin", "*"); 34 | ctx.response.headers.set( 35 | "Access-Control-Allow-Headers", 36 | "Origin, X-Requested-With, Content-Type, Accept, Authorization", 37 | ); 38 | ctx.response.headers.set( 39 | "Access-Control-Allow-Methods", 40 | "GET, POST, PUT, DELETE, OPTIONS", 41 | ); 42 | await next(); 43 | }); 44 | 45 | app.use(async (ctx, next) => { 46 | if (["/", "/styles.css", "/script.js"].includes(ctx.request.url.pathname)) { 47 | // since we use router.get("/:param") we need to first ensure 48 | // that the param is not styles.css or script.js 49 | await ctx.send({root: './public/', index: 'index.html'}); 50 | } 51 | else { 52 | // if it is not styles.css or script.js we continue to the next middleware 53 | // i.e, the router 54 | await next(); 55 | } 56 | }); 57 | app.use(router.routes()); 58 | app.use(router.allowedMethods()); 59 | 60 | 61 | app.addEventListener("error", (e) => console.log(e)); 62 | 63 | console.log("> Started listeneing on PORT 8000!"); 64 | 65 | await app.listen({ port: 8000 }); 66 | -------------------------------------------------------------------------------- /env.ts: -------------------------------------------------------------------------------- 1 | /* 2 | (c) @xditya 3 | View the license: https://github.com/xditya/WebShortener/blob/master/LICENSE 4 | */ 5 | 6 | import { config } from "https://deno.land/std@0.154.0/dotenv/mod.ts"; 7 | import { cleanEnv, url } from "https://deno.land/x/envalid@0.1.2/mod.ts"; 8 | 9 | await config({ export: true }); 10 | 11 | export default cleanEnv(Deno.env.toObject(), { 12 | MONGO_URL: url(), 13 | }); 14 | -------------------------------------------------------------------------------- /public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1954 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | Link Shortener 10 | 11 | 12 | 13 | 14 | 15 |
16 |

Link Shortener

17 |

Shorten your links, for free!

18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 | 35 | 36 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/script.js: -------------------------------------------------------------------------------- 1 | /* 2 | (c) @xditya 3 | View the license: https://github.com/xditya/WebShortener/blob/master/LICENSE 4 | */ 5 | 6 | const isValidUrl = (urlString) => { 7 | const urlPattern = new RegExp( 8 | "^(https?:\\/\\/)" + // validate protocol 9 | "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // validate domain name 10 | "((\\d{1,3}\\.){3}\\d{1,3}))" + // validate OR ip (v4) address 11 | "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // validate port and path 12 | "(\\?[;&a-z\\d%_.~+=-]*)?" + // validate query string 13 | "(\\#[-a-z\\d_]*)?$", 14 | "i" 15 | ); // validate fragment locator 16 | return !!urlPattern.test(urlString); 17 | }; 18 | 19 | document 20 | .getElementById("shortenForm") 21 | .addEventListener("submit", async function (event) { 22 | event.preventDefault(); // Prevent form submission 23 | 24 | const longURL = document.getElementById("longURL").value; 25 | 26 | // Check if the input is empty 27 | if (longURL.trim() === "") { 28 | alert("Please enter a URL"); 29 | return; 30 | } 31 | if (!isValidUrl(longURL)) { 32 | alert("Please enter a valid URL"); 33 | return; 34 | } 35 | // Get the shortened link 36 | const res = await fetch("/shorten", { 37 | method: "POST", 38 | headers: { 39 | "Content-Type": "application/json", 40 | }, 41 | body: JSON.stringify({ url: longURL }), 42 | }); 43 | const data = await res.json(); 44 | if (data.error) { 45 | alert(data.error); 46 | return; 47 | } 48 | if (data.hash) { 49 | shortenedLink = data.hash; 50 | } 51 | 52 | // Show the custom popup with the shortened link 53 | showPopup(window.location.href + shortenedLink); 54 | 55 | // Clear the input box after showing the popup 56 | document.getElementById("longURL").value = ""; 57 | }); 58 | 59 | function closePopup() { 60 | const popup = document.getElementById("customPopup"); 61 | popup.style.display = "none"; 62 | } 63 | 64 | function showPopup(shortenedLink) { 65 | const popup = document.getElementById("customPopup"); 66 | const shortLinkElement = document.getElementById("shortLink"); 67 | shortLinkElement.textContent = shortenedLink; 68 | shortLinkElement.href = shortenedLink; // Set the "href" attribute to the shortened link 69 | popup.style.display = "block"; 70 | } 71 | function copyToClipboard() { 72 | const shortLinkElement = document.getElementById("shortLink"); 73 | const shortenedLink = shortLinkElement.href; 74 | 75 | navigator.clipboard 76 | .writeText(shortenedLink) 77 | .then(function () { 78 | alert("Copied: " + shortenedLink); 79 | }) 80 | .catch(function (err) { 81 | console.error("Failed to copy: ", err); 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | (c) @xditya 3 | View the license: https://github.com/xditya/WebShortener/blob/master/LICENSE 4 | */ 5 | 6 | body { 7 | font-family: Arial, sans-serif; 8 | margin: 0; 9 | padding: 0; 10 | background-color: #000; 11 | /* Black background color */ 12 | color: #fff; 13 | /* Light text color */ 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | height: 100vh; 18 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Cpath d='M1 3h2M2 2h1M1 1h1M1 2h1M1 0h1M2 1h1M1 3v1' stroke='%23fff' stroke-opacity='.2'/%3E%3C/svg%3E"); 19 | /* Add diagonal crosshatch pattern to the background */ 20 | background-repeat: repeat; 21 | } 22 | 23 | .container { 24 | width: 80%; 25 | max-width: 500px; 26 | padding: 20px; 27 | background-color: #222; 28 | /* Darker container background color */ 29 | border-radius: 8px; 30 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); 31 | } 32 | 33 | .footer { 34 | bottom: 0; 35 | text-align: center; 36 | position: absolute; 37 | } 38 | 39 | .footer a { 40 | color: #007bff; 41 | text-decoration: none; 42 | margin: 5px; 43 | } 44 | 45 | h1 { 46 | text-align: center; 47 | color: #fff; 48 | /* Light text color for headings */ 49 | } 50 | 51 | p { 52 | text-align: center; 53 | color: #ccc; 54 | /* Light text color for paragraphs */ 55 | } 56 | 57 | form { 58 | display: flex; 59 | flex-direction: column; 60 | } 61 | 62 | input[type="text"] { 63 | padding: 10px; 64 | font-size: 16px; 65 | border: 1px solid #666; 66 | /* Darker border color */ 67 | border-radius: 4px; 68 | margin-bottom: 10px; 69 | background-color: #333; 70 | /* Darker input background color */ 71 | color: #fff; 72 | /* Light text color for input text */ 73 | } 74 | 75 | input[type="submit"] { 76 | padding: 12px 20px; 77 | font-size: 16px; 78 | background-color: #004080; 79 | /* Darker shade of blue */ 80 | color: #fff; 81 | border: none; 82 | border-radius: 4px; 83 | cursor: pointer; 84 | } 85 | 86 | input[type="submit"]:hover { 87 | background-color: #00264d; 88 | /* Darker shade of blue on hover */ 89 | } 90 | 91 | h2 { 92 | font-size: 20px; 93 | color: #fff; 94 | /* Light text color for headings */ 95 | margin-top: 0; 96 | } 97 | 98 | /* Custom Popup Styles */ 99 | .popup { 100 | display: none; 101 | position: fixed; 102 | top: 0; 103 | left: 0; 104 | width: 100%; 105 | height: 100%; 106 | background-color: rgba(0, 0, 0, 0.7); 107 | } 108 | 109 | .popup-content { 110 | position: absolute; 111 | top: 50%; 112 | left: 50%; 113 | transform: translate(-50%, -50%); 114 | background-color: #333; 115 | /* Darker popup background color */ 116 | padding: 20px; 117 | border-radius: 8px; 118 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); 119 | max-width: 80%; 120 | /* Limit the maximum width of the popup */ 121 | text-align: center; 122 | /* Center the content within the popup */ 123 | } 124 | 125 | #shortenedLink { 126 | color: #007bff; 127 | /* Light blue color for the URL link */ 128 | } 129 | 130 | #shortenedLink a { 131 | color: #007bff; 132 | /* Light blue color for the URL link */ 133 | text-decoration: none; 134 | } 135 | 136 | #shortenedLink a:hover { 137 | text-decoration: underline; 138 | /* Underline the URL link on hover */ 139 | } 140 | 141 | #copyButton { 142 | padding: 10px 20px; 143 | font-size: 16px; 144 | background-color: #004080; 145 | /* Darker shade of blue */ 146 | color: #fff; 147 | border: none; 148 | border-radius: 4px; 149 | cursor: pointer; 150 | margin-top: 10px; 151 | } 152 | 153 | #copyButton:hover { 154 | background-color: #00264d; 155 | /* Darker shade of blue on hover */ 156 | } 157 | 158 | .close-button { 159 | position: absolute; 160 | top: 10px; 161 | right: 10px; 162 | font-size: 18px; 163 | cursor: pointer; 164 | color: #ccc; 165 | /* Light close button icon color */ 166 | } 167 | 168 | .close-button:hover { 169 | color: #fff; 170 | /* Light close button icon color on hover */ 171 | } 172 | 173 | /* Media Queries for Mobile */ 174 | @media only screen and (max-width: 600px) { 175 | .container { 176 | width: 95%; 177 | } 178 | } --------------------------------------------------------------------------------