initial commit

This commit is contained in:
Arshdeep Singh 2025-05-28 11:33:08 +05:30
commit a7175ebd36
8 changed files with 2081 additions and 0 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
DB_PATH="PATH FOR DB FILE"

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
database/

104
README.md Normal file
View file

@ -0,0 +1,104 @@
# 🔗 Link Shortener Plugin
A simple, pluggable Node.js module for shortening URLs using SQLite, with support for custom expiry dates and automatic cleanup via cron jobs.
---
## 📦 Features
- ✅ Shorten long URLs with optional expiry dates
- ✅ Retrieve the original URL from a short code
- ✅ Auto-deletes expired links using a daily cron job
- ✅ Uses SQLite for lightweight persistence
- ✅ No external APIs required
---
## 🛠️ Installation
```bash
npm install digi-url-shortener
```
Make sure you have Node.js v18+ installed.
## 📁 Project Structure
```bash
link-shortener/
├── database/
│ └── links.db # SQLite DB (created automatically)
├── utils/
│ └── helperFunctions.js # Core logic (shorten, retrieve, cleanup)
├── dbConfig/
│ └── index.js # SQLite CRUD operations
├── index.js # Entry point or API setup
├── .env.example # Environment variables
├── package.json
└── README.md
```
## ⚙️ Configuration
```bash
DB_PATH=./database/links.db
```
## 🚀 Usage
### Import Functions
```js
const {
getShortenUrlCode,
getOriginalUrlByShortCode,
deleteExpiredShortUrlsCodes,
} = require("./interface");
```
### Create a Short URL
```js
const shortCode = await getShortenUrlCode({
longUrl: "https://example.com",
expiryInDays: 7,
codeLength: 10,
});
console.log("Short code:", shortCode);
```
### Get Original URL
```js
const originalUrl = await getOriginalUrlByShortCode("shortCode123");
console.log("Original URL:", originalUrl);
```
### Start Expired Link Cleanup
```js
deleteExpiredShortUrlsCodes();
```
### Example
```js
(async () => {
const shortCode = await getShortenUrlCode({
longUrl: "https://example.com",
expiryInDays: 5,
codeLength: 10,
});
console.log("Short code is:", shortCode);
const original = await getOriginalUrlByShortCode(shortCode);
console.log("Original URL:", original);
})();
```
## License 📝
This project is licensed under the ISC License.
## Author ✍️
DigiMantra

285
dbConfig/index.js Normal file
View file

@ -0,0 +1,285 @@
// Import necessary modules
const sqlite3 = require("sqlite3").verbose();
const { config } = require("dotenv");
const moment = require("moment");
const path = require("path");
const fs = require("fs");
// Load environment variables from .env file
config();
// Resolve the target directory path for the database folder
const targetPath = path.resolve(process.cwd(), "./database");
// Ensure the database directory exists; create it if it doesn't
if (!fs.existsSync(targetPath)) {
fs.mkdirSync(targetPath, { recursive: true });
console.log("✅ Created db folder at project root");
} else {
console.log(" db folder already exists");
}
// Determine SQLite DB file path from environment or use default
const dbPath = process.env.DB_PATH || path.join(targetPath, "links.db");
/**
* Initializes the SQLite database and creates the `urls` table and triggers if they don't exist.
* Ensures unique slugs per user or for anonymous users.
* @returns {Promise<sqlite3.Database>} - Resolves with the database instance.
*/
const initDB = async () => {
return new Promise(async (resolve, reject) => {
const db = new sqlite3.Database(dbPath, async (err) => {
if (err) {
console.error("Failed to open the database", err);
reject(err);
} else {
console.log("Database connected");
// Create the `urls` table with constraints
db.run(
`CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
long_url TEXT NOT NULL,
short_code TEXT NOT NULL,
expiry_date TEXT,
user_id TEXT DEFAULT '',
customized_shortner_slug TEXT,
UNIQUE(user_id, customized_shortner_slug)
)`,
function (err) {
if (err) {
console.error("Error creating table:", err);
reject(err);
} else {
// Create trigger to enforce slug uniqueness logic before insert
db.run(
`
CREATE TRIGGER IF NOT EXISTS enforce_custom_slug_uniqueness_insert
BEFORE INSERT ON urls
BEGIN
-- Prevent duplicate slugs for anonymous users
SELECT RAISE(ABORT, 'Slug must be unique for anonymous users')
WHERE NEW.user_id = '' AND NEW.customized_shortner_slug != '' AND EXISTS (
SELECT 1 FROM urls
WHERE user_id = '' AND customized_shortner_slug = NEW.customized_shortner_slug
);
-- Prevent duplicate slugs for the same logged-in user
SELECT RAISE(ABORT, 'Slug must be unique per user')
WHERE NEW.user_id != '' AND NEW.customized_shortner_slug != '' AND EXISTS (
SELECT 1 FROM urls
WHERE user_id = NEW.user_id AND customized_shortner_slug = NEW.customized_shortner_slug
);
END;
`,
function (err) {
if (err) reject(err);
resolve(db);
}
);
}
}
);
}
});
});
};
/**
* Inserts a new shortened URL record into the database.
* @param {string} longUrl - The original full URL.
* @param {string} shortCode - The generated short code.
* @param {string} expiryDate - The expiration date (format: 'YYYY-MM-DD HH:mm:ss').
* @param {string} user_id - The ID of the user (empty string for anonymous).
* @param {string} customized_shortner_slug - Optional custom slug provided by user.
* @returns {Promise<number>} - Resolves with the new record's ID.
*/
const insertUrl = async (
longUrl,
shortCode,
expiryDate,
user_id,
customized_shortner_slug
) => {
const db = await initDB();
return new Promise((resolve, reject) => {
const sql = `INSERT INTO urls (long_url, short_code, expiry_date, user_id,customized_shortner_slug) VALUES (?, ?, ?, ?, ?)`;
db.run(
sql,
[longUrl, shortCode, expiryDate, user_id, customized_shortner_slug],
function (err) {
if (err) {
if (err.code === "SQLITE_CONSTRAINT") {
reject(
new Error(
"The custom slug is already taken for this user. Please choose a different one."
)
);
}
reject(err);
} else {
resolve(this.lastID); // Return the ID of the inserted row
}
}
);
});
};
/**
* Looks up a short URL by its code or custom slug.
* If the URL has expired, deletes the record and rejects the promise.
* @param {string} shortCode - The short code or slug.
* @param {string} userId - The user ID to validate ownership (used during deletion).
* @returns {Promise<string>} - Resolves with the original long URL.
* @throws {Error} - If not found or expired.
*/
const findByShortCode = async (shortCode, userId = "") => {
const db = await initDB();
return new Promise((resolve, reject) => {
const sql = `SELECT * FROM urls WHERE short_code = ? or customized_shortner_slug = ?`;
db.get(sql, [shortCode, shortCode], (err, row) => {
if (err) {
reject(err);
} else {
if (row) {
const now = new Date();
const expiryDate = new Date(row.expiry_date);
// Delete and reject if the link has expired
if (expiryDate < now) {
if (row?.short_code === shortCode)
db.run("DELETE FROM urls WHERE short_code = ? and user_id = ?", [
shortCode,
userId,
]);
else if (row?.customized_shortner_slug === shortCode)
db.run(
"DELETE FROM urls WHERE customized_shortner_slug = ? and user_id = ?",
[shortCode, userId]
);
reject(new Error("This link has expired."));
} else {
resolve(row.long_url);
}
} else {
reject(new Error("Short code not found."));
}
}
});
});
};
/**
* Checks if a URL already exists with the same expiry date and user.
* Useful for avoiding duplicate entries.
* @param {string} longUrl - The long/original URL.
* @param {string} expiryDate - The expiry date to check against.
* @param {string} user_id - The user ID.
* @returns {Promise<Object|null>} - Resolves with the row if found, otherwise null.
*/
const findByLongUrl = async (
longUrl,
expiryDate,
userId,
customizedShortnerSlug
) => {
const db = await initDB();
return new Promise((resolve, reject) => {
const sql = `SELECT * FROM urls WHERE long_url = ? and expiry_date <= ? and user_id = ? and customized_shortner_slug = ?`;
db.get(
sql,
[longUrl, expiryDate, userId, customizedShortnerSlug],
(err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
}
);
});
};
/**
* Retrieves all URL records from the database.
* @returns {Promise<Array>} - Resolves with an array of all records.
*/
const getAllData = async () => {
const db = await initDB();
return new Promise((resolve, reject) => {
const sql = `SELECT * FROM urls`;
db.all(sql, (err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
};
/**
* Deletes all records that have expired (based on current time).
* @returns {Promise<string>} - Resolves with a message indicating how many records were deleted.
*/
const deleteAllExpired = async () => {
const db = await initDB();
return new Promise((resolve, reject) => {
const currentTime = moment().format("YYYY-MM-DD HH:mm:ss");
const sql = `DELETE FROM urls WHERE expiry_date < ?`;
db.run(sql, [currentTime], function (err) {
if (err) {
reject(err);
} else {
resolve(`${this.changes} expired URLs deleted.`);
}
});
});
};
/*
* Retrieves paginated URL records from the database.
* @param {number} page - The page number (starting from 1).
* @param {number} limit - Number of records per page.
* @returns {Promise<Object>} - Resolves with an object containing total count, current page, limit, and data.
*/
const getPaginatedData = async (page = 1, limit = 10, userId = "") => {
const db = await initDB();
const offset = (page - 1) * limit;
userId = String(userId).trim();
return new Promise((resolve, reject) => {
const sqlData = `SELECT * FROM urls where user_id = ? LIMIT ? OFFSET ?`;
const sqlCount = `SELECT COUNT(*) as count FROM urls where user_id = ? `;
db.all(sqlData, [userId, limit, offset], (err, rows) => {
if (err) {
reject(err);
} else {
db.get(sqlCount, [userId], (countErr, countRow) => {
if (countErr) {
reject(countErr);
} else {
resolve({
page,
limit,
total: countRow.count,
data: rows,
});
}
});
}
});
});
};
// Export all database utility functions
module.exports = {
initDB,
insertUrl,
findByShortCode,
findByLongUrl,
getAllData,
deleteAllExpired,
getPaginatedData,
};

14
index.js Normal file
View file

@ -0,0 +1,14 @@
const {
createShortUrl,
getOriginalUrl,
startScheduler,
getAllData
} = require("./utils/helperFunctions");
module.exports = {
getShortenUrlCode: createShortUrl,
getOriginalUrlByShortCode: getOriginalUrl,
deleteExpiredShortUrlsCodes: startScheduler,
getAllData
};

1508
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "digi-url-shortener",
"version": "1.0.0",
"description": "A simple Node.js plugin for link shortening",
"main": "index.js",
"keywords": [
"link shortener",
"url",
"plugin",
"nodejs"
],
"author": "DigiMantra",
"license": "MIT",
"dependencies": {
"dotenv": "^16.5.0",
"moment": "^2.30.1",
"node-cron": "^4.0.7",
"sqlite3": "^5.1.7"
},
"engines": {
"node": ">=18.0.0"
}
}

144
utils/helperFunctions.js Normal file
View file

@ -0,0 +1,144 @@
const moment = require("moment");
const db = require("../dbConfig");
const crypto = require("crypto");
const cron = require("node-cron");
/**
* Checks if a given string is a valid URL format.
* @param {string} url - The input string to validate.
* @returns {boolean} - Returns true if valid URL format, otherwise false.
*/
function isValidUrl(url) {
try {
new URL(url);
return true;
} catch (e) {
return false;
}
}
/**
* Generates a deterministic short code based on long URL, expiry date, and user ID.
* Uses MD5 hashing, encodes the result in base64, and slices to desired length.
* @param {string} longUrl - Original input URL.
* @param {number} codeLength - Desired length of output short code.
* @param {string} expiryDate - Expiry date used in code generation.
* @param {string} user_id - User identifier to namespace the code.
* @returns {string} - Base64-based truncated hash used as short code.
*/
const generateShortCode = (
longUrl,
codeLength,
expiryDate,
userId,
customizedShortnerSlug
) => {
const dataToHash =
longUrl + "_" + expiryDate + "_" + userId + "_" + customizedShortnerSlug;
return crypto
.createHash("md5")
.update(dataToHash)
.digest("base64")
.substring(0, codeLength);
};
/**
* Creates and stores a new short code mapping for a given URL.
* Validates input values and optionally supports user-scoped or custom slugs.
* @param {object} params - Input parameters for short code creation.
* @param {string} params.longUrl - Original full URL.
* @param {number} params.expiryInDays - Days until expiry (030 allowed).
* @param {number} params.codeLength - Short code length (830 allowed).
* @param {string} [params.user_id=""] - Optional user ID for namespace isolation.
* @param {string} [params.customized_shortner_slug=""] - Optional custom alias.
* @returns {Promise<string>} - Returns existing or newly created code/slug.
* @throws {Error} - If input validation fails or database errors occur.
*/
const createShortUrl = async ({
longUrl = "",
expiryInDays = 30,
codeLength = 10,
userId = "",
customizedShortnerSlug = "",
}) => {
if (!String(longUrl).trim() || !isValidUrl(longUrl)) {
throw new Error("Long url required.");
}
if (!Number.isInteger(codeLength)) {
throw new Error("Code length must be valid integer value.");
} else if (codeLength < 8 || codeLength > 30) {
throw new Error("Code length must be between 8 to 30 characters.");
}
if (!Number.isInteger(expiryInDays)) {
throw new Error("Expiry in days must be valid integer value.");
} else if (expiryInDays < 0 || expiryInDays > 30) {
throw new Error("Expiry in days must be between 0 to 30 days.");
}
const expiryDate = moment()
.add(expiryInDays, "days")
.endOf("day")
.format("YYYY-MM-DD HH:mm:ss");
// Avoid duplicates: return existing code if it already exists
const existing = await db.findByLongUrl(
longUrl,
expiryDate,
userId,
customizedShortnerSlug
);
// if (existing) return existing.customized_shortner_slug || existing.short_code;
const shortCode = generateShortCode(
longUrl,
codeLength,
expiryDate,
userId,
customizedShortnerSlug
);
const res = await db.insertUrl(
longUrl,
shortCode,
expiryDate,
String(userId).trim(),
String(customizedShortnerSlug).trim()
);
return customizedShortnerSlug || shortCode;
};
/**
* Retrieves the original long URL using a short code or custom slug.
* @param {string} shortCode - Code or slug used to look up the original URL.
* @param {string} [userId=""] - Optional user ID for user-specific resolution.
* @returns {Promise<string>} - Returns the original URL if found and valid.
* @throws {Error} - If lookup fails or URL is expired.
*/
const getOriginalUrl = async (shortCode, userId = "") => {
try {
const longUrl = await db.findByShortCode(shortCode, userId);
return longUrl;
} catch (error) {
throw error;
}
};
/**
* Starts a cron job that runs daily at midnight.
* Cleans up expired short URL entries from the database.
*/
const startScheduler = () => {
console.log("Starting expiration cleanup scheduler...");
cron.schedule("0 0 * * *", () => {
console.log("[Scheduler] Running deletion job...");
db.deleteAllExpired().then(console.log).catch(console.error);
});
};
module.exports = {
createShortUrl,
getOriginalUrl,
startScheduler,
getAllData: db.getAllData,
getPaginatedData: db.getPaginatedData,
};