286 lines
9.1 KiB
JavaScript
286 lines
9.1 KiB
JavaScript
// 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,
|
||
};
|