UNPKG

r2c-url-shortener

Version:

Quick, Reusable, Reliable, Collision-free URL shortener with Redis and Postgres support

148 lines (126 loc) 4.33 kB
const { v4: uuidv4 } = require("uuid"); const moment = require("moment"); const { getConnections } = require("../configs/init.config"); const { AppError } = require("../utils/appError.utils"); const { subtle } = require("crypto").webcrypto; /** * Quickly shorten a long messy URL, persist in postgres DB, uses redis for faster lookup. * Collision safe, built upon SHA-256 hashing. * @param {longUrl} body * @param {baseUrl, maxCollisionAttempts, urlCachingInMinutes} options * @returns Object containing the resultant short URL * @author Kushagra Ranjan */ const openCompression = async (body, options) => { try { const { pool, redisClient } = await getConnections(); const COLLISION_RESOLUTION_MAX_ATTEMPTS = options?.maxCollisionAttempts || 50; const REDIS_LONG_URL_EXPIRE_DURATION = options?.urlCachingInMinutes || 45; const BASE_URL = options?.baseUrl || "https://srt.ly"; if (!body || !body.longUrl) { throw new AppError(400, "Invalid Request"); } const { longUrl } = body; let attempt = 0; let hashedUrl, shortCode, salt = ""; while (attempt < COLLISION_RESOLUTION_MAX_ATTEMPTS) { salt = attempt === 0 ? "" : uuidv4(); hashedUrl = await hashUrl(longUrl, salt); shortCode = getShortCode(hashedUrl, 7 + attempt); const existingLongUrl = await redisClient.get(`shortCode:${shortCode}`); if (existingLongUrl === longUrl) { break; } if (!existingLongUrl) { try { const id = uuidv4(); const timestamp = moment().toISOString(); await pool.query( `INSERT INTO urls (id, short_code, long_url, created_at) VALUES ($1, $2, $3, $4)`, [id, shortCode, longUrl, timestamp] ); await redisClient.set(`shortCode:${shortCode}`, longUrl, { EX: REDIS_LONG_URL_EXPIRE_DURATION * 60, }); break; } catch (error) { if (error.code == "23505") continue; else throw new AppError(500, "Something went wrong"); } } attempt++; } if (attempt === 50) { throw new AppError(500, "Too many collisions. Please try again."); } return { code: 200, shortUrl: `${BASE_URL}` + "/" + `${shortCode}`, }; } catch (error) { throw new AppError( error.statusCode || 500, error.message || "Something went wrong" ); } }; /** * Redirect to the actual page where the given shortUrl param points to. * @param {shortUrl} params * @returns Redirection * @author Kushagra Ranjan */ const redirectController = async (params) => { try { const { pool, redisClient } = await getConnections(); const { shortUrl } = params; const shortCode = new URL(shortUrl).pathname.split('/')[1]; if (!shortCode) throw new AppError(400, "Invalid Request"); let longUrl = await redisClient.get(`shortCode:${shortCode}`); if (!longUrl) { const result = await pool.query( "SELECT long_url FROM urls WHERE short_code = $1", [shortCode] ); if (result?.rowCount > 0) { longUrl = result.rows[0].long_url; await redisClient.set(`shortCode:${shortCode}`, longUrl, { EX: REDIS_LONG_URL_EXPIRE_DURATION * 60, }); } } if (!longUrl) throw new AppError(204, "Requested URL no longer exist"); return { code: 200, url: longUrl, }; } catch (error) { throw new AppError( error.statusCode || 500, error.message || "Something went wrong, please try again" ); } }; const hashUrl = async (url, salt = "") => { try { const encoder = new TextEncoder(); const data = encoder.encode(url + salt); const buffer = await subtle.digest("SHA-256", data); return Array.from(new Uint8Array(buffer)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } catch (error) { console.error("error occured in hashUrl: ", error); throw new AppError(500, "Something went wrong"); } }; const getShortCode = (hash, length = 6) => { return hash.slice(0, length); }; module.exports = { openCompression, redirectController, };