initial commit
This commit is contained in:
commit
a7175ebd36
1
.env.example
Normal file
1
.env.example
Normal file
|
@ -0,0 +1 @@
|
|||
DB_PATH="PATH FOR DB FILE"
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
database/
|
104
README.md
Normal file
104
README.md
Normal 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
285
dbConfig/index.js
Normal 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
14
index.js
Normal 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
1508
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
package.json
Normal file
23
package.json
Normal 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
144
utils/helperFunctions.js
Normal 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 (0–30 allowed).
|
||||
* @param {number} params.codeLength - Short code length (8–30 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,
|
||||
};
|
Loading…
Reference in a new issue