r2c-url-shortener
Version:
Quick, Reusable, Reliable, Collision-free URL shortener with Redis and Postgres support
148 lines (126 loc) • 4.33 kB
JavaScript
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,
};