feat: initial commit - EZBio Next.js 16 fullstack URL shortener & bio link

This commit is contained in:
Tien Tran committed 2026-09-28 00:28:09 -07:00
commit 569c10342f
64 files changed
+15670

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import { UAParser } from "ua-parser-js";
import { prisma } from "./prisma";
export interface ClickTrackingData {
shortUrlId: string;
ipAddress?: string;
userAgent?: string;
referrer?: string;
headers?: Headers;
}
export async function recordUrlClick(data: ClickTrackingData): Promise<void> {
try {
const { shortUrlId, ipAddress, userAgent, referrer, headers } = data;
// Parse User Agent
const parser = new UAParser(userAgent || "");
const device = parser.getDevice();
const os = parser.getOS();
const browser = parser.getBrowser();
let deviceType = "desktop";
if (device.type === "mobile") deviceType = "mobile";
else if (device.type === "tablet") deviceType = "tablet";
// Detect GeoIP via Vercel / Cloudflare edge headers
let countryCode = headers?.get("x-vercel-ip-country") || headers?.get("cf-ipcountry") || null;
let city = headers?.get("x-vercel-ip-city") || null;
let countryName = countryCode;
// Map common country codes to country names
if (countryCode && typeof countryCode === "string") {
try {
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
countryName = regionNames.of(countryCode) || countryCode;
} catch {
countryName = countryCode;
}
}
// Save click record
await prisma.$transaction([
prisma.urlClick.create({
data: {
shortUrlId,
ipAddress: ipAddress || null,
userAgent: userAgent ? userAgent.slice(0, 500) : null,
deviceType,
operatingSystem: os.name ? `${os.name} ${os.version || ""}`.trim() : "Unknown",
browser: browser.name ? `${browser.name} ${browser.version || ""}`.trim() : "Unknown",
countryName: countryName || "Unknown",
countryCode: countryCode || "XX",
city: city ? decodeURIComponent(city) : null,
referrer: referrer ? referrer.slice(0, 500) : null,
},
}),
prisma.shortUrl.update({
where: { id: shortUrlId },
data: {
clicksCount: { increment: 1 },
},
}),
]);
} catch (error) {
console.error("Error recording click analytics:", error);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
import { prisma } from "./prisma";
const SECRET = new TextEncoder().encode(
process.env.AUTH_SECRET || "ezbio-super-secure-jwt-secret-key-32-characters-min"
);
const COOKIE_NAME = "ezbio_session";
export interface UserSession {
id: string;
email: string;
name: string;
username: string;
role: string;
}
export async function signSession(payload: UserSession): Promise<string> {
return await new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(SECRET);
}
export async function verifySession(token: string): Promise<UserSession | null> {
try {
const { payload } = await jwtVerify(token, SECRET);
return payload as unknown as UserSession;
} catch {
return null;
}
}
export async function getCurrentUser(): Promise<UserSession | null> {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
const session = await verifySession(token);
if (!session) return null;
// Optional: check if user is still active in DB
const user = await prisma.user.findUnique({
where: { id: session.id },
select: { id: true, email: true, name: true, username: true, role: true, isActive: true },
});
if (!user || !user.isActive) return null;
return {
id: user.id,
email: user.email,
name: user.name,
username: user.username,
role: user.role,
};
}
export async function setAuthCookie(token: string) {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60, // 7 days
});
}
export async function clearAuthCookie() {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+53
View File
@@ -0,0 +1,53 @@
import QRCode from "qrcode";
export interface QROptions {
color?: string; // hex foreground
background?: string; // hex background or 'transparent'
size?: number;
margin?: number;
}
export async function generateQRCodeDataUrl(text: string, options: QROptions = {}): Promise<string> {
const { color = "#000000", background, size = 300, margin = 1 } = options;
const qrColor = {
dark: color.startsWith("#") ? color : `#${color}`,
light: background
? background.toLowerCase() === "transparent"
? "#00000000"
: background.startsWith("#")
? background
: `#${background}`
: "#00000000",
};
return await QRCode.toDataURL(text, {
errorCorrectionLevel: "H",
margin,
width: size,
color: qrColor,
});
}
export async function generateQRCodeSVG(text: string, options: QROptions = {}): Promise<string> {
const { color = "#000000", background, size = 300, margin = 1 } = options;
const qrColor = {
dark: color.startsWith("#") ? color : `#${color}`,
light: background
? background.toLowerCase() === "transparent"
? "#00000000"
: background.startsWith("#")
? background
: `#${background}`
: "#00000000",
};
return await QRCode.toString(text, {
type: "svg",
errorCorrectionLevel: "H",
margin,
width: size,
color: qrColor,
});
}
+62
View File
@@ -0,0 +1,62 @@
import { prisma } from "./prisma";
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
/**
* Get the configured default short code length from admin settings.
* Defaults to 5 if not configured.
*/
export async function getDefaultUrlLength(): Promise<number> {
try {
const setting = await prisma.setting.findUnique({
where: { key: "default_url_length" },
});
if (setting && setting.value) {
const len = parseInt(setting.value, 10);
if (!isNaN(len) && len >= 3 && len <= 16) {
return len;
}
}
} catch (error) {
console.error("Error reading default_url_length:", error);
}
return 5;
}
/**
* Generate a random short code string of a given length.
*/
export function generateRandomCode(length: number): string {
let result = "";
for (let i = 0; i < length; i++) {
result += CHARS.charAt(Math.floor(Math.random() * CHARS.length));
}
return result;
}
/**
* Generate a guaranteed unique short code based on configured default length.
*/
export async function generateUniqueShortCode(customLength?: number): Promise<string> {
const length = customLength || (await getDefaultUrlLength());
let code = "";
let exists = true;
let attempts = 0;
while (exists && attempts < 15) {
code = generateRandomCode(length);
const existing = await prisma.shortUrl.findUnique({
where: { shortCode: code },
select: { id: true },
});
exists = Boolean(existing);
attempts++;
}
if (exists) {
// If collides after multiple attempts, bump length by 1
code = generateRandomCode(length + 1);
}
return code;
}
+166
View File
@@ -0,0 +1,166 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { prisma } from "./prisma";
import fs from "fs/promises";
import path from "path";
// Initialize S3 client for Cloudflare R2
const isR2Configured = Boolean(
process.env.CLOUDFLARE_R2_ACCOUNT_ID &&
process.env.CLOUDFLARE_R2_ACCESS_KEY_ID &&
process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY &&
process.env.CLOUDFLARE_R2_BUCKET_NAME
);
const r2Client = isR2Configured
? new S3Client({
region: "auto",
endpoint: `https://${process.env.CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY!,
},
})
: null;
export interface UploadOptions {
buffer: Buffer;
fileName: string;
mimeType: string;
category: "avatar" | "background" | "logo" | "favicon" | "og_image";
userId?: string;
}
export async function uploadFile(options: UploadOptions): Promise<{ url: string; key: string }> {
const { buffer, fileName, mimeType, category, userId } = options;
const ext = path.extname(fileName) || "";
const uniqueKey = `${category}/${Date.now()}_${Math.random().toString(36).substring(2, 9)}${ext}`;
const sizeBytes = BigInt(buffer.length);
let fileUrl = "";
let storageType = "local";
if (isR2Configured && r2Client && process.env.STORAGE_DRIVER !== "local") {
// Upload to Cloudflare R2
storageType = "cloudflare_r2";
await r2Client.send(
new PutObjectCommand({
Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME!,
Key: uniqueKey,
Body: buffer,
ContentType: mimeType,
})
);
const publicBase = (process.env.CLOUDFLARE_R2_PUBLIC_URL || "").replace(/\/$/, "");
fileUrl = publicBase ? `${publicBase}/${uniqueKey}` : `/api/storage/${uniqueKey}`;
} else {
// Fallback: Local Storage in public/uploads
storageType = "local";
const uploadDir = path.join(process.cwd(), "public", "uploads", category);
await fs.mkdir(uploadDir, { recursive: true });
const localFilePath = path.join(uploadDir, path.basename(uniqueKey));
await fs.writeFile(localFilePath, buffer);
fileUrl = `/uploads/${category}/${path.basename(uniqueKey)}`;
}
// Record in database to monitor Cloudflare R2 storage usage
try {
await prisma.uploadedFile.create({
data: {
key: uniqueKey,
url: fileUrl,
originalName: fileName,
mimeType,
sizeBytes,
category,
storageType,
userId: userId || null,
},
});
} catch (error) {
console.error("Failed to record uploaded file in DB:", error);
}
return { url: fileUrl, key: uniqueKey };
}
export async function deleteFile(key: string): Promise<boolean> {
try {
const fileRecord = await prisma.uploadedFile.findUnique({
where: { key },
});
if (fileRecord?.storageType === "cloudflare_r2" && r2Client) {
await r2Client.send(
new DeleteObjectCommand({
Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME!,
Key: key,
})
);
} else {
// Local file
const localFilePath = path.join(process.cwd(), "public", "uploads", key);
try {
await fs.unlink(localFilePath);
} catch {
// Ignore if file doesn't exist
}
}
await prisma.uploadedFile.deleteMany({
where: { key },
});
return true;
} catch (error) {
console.error("Error deleting file:", error);
return false;
}
}
export interface StorageStatus {
usedBytes: number;
usedMB: number;
usedGB: number;
limitMB: number;
limitGB: number;
percentUsed: number;
isWarning: boolean; // >= 80%
isCritical: boolean; // >= 90%
totalFiles: number;
storageDriver: string;
}
/**
* Get current storage usage and check if approaching Cloudflare R2 Free Tier limit (10GB)
*/
export async function getStorageStatus(): Promise<StorageStatus> {
const files = await prisma.uploadedFile.findMany({
select: { sizeBytes: true },
});
const totalBytes = files.reduce((acc, f) => acc + Number(f.sizeBytes), 0);
const usedMB = Number((totalBytes / (1024 * 1024)).toFixed(2));
const usedGB = Number((totalBytes / (1024 * 1024 * 1024)).toFixed(3));
// Default free limit: 10 GB = 10,240 MB
const limitMB = Number(process.env.CLOUDFLARE_R2_FREE_LIMIT_MB || 10240);
const limitGB = Number((limitMB / 1024).toFixed(1));
const percentUsed = Number(((usedMB / limitMB) * 100).toFixed(1));
const isWarning = percentUsed >= 80 && percentUsed < 90;
const isCritical = percentUsed >= 90;
return {
usedBytes: totalBytes,
usedMB,
usedGB,
limitMB,
limitGB,
percentUsed,
isWarning,
isCritical,
totalFiles: files.length,
storageDriver: isR2Configured && process.env.STORAGE_DRIVER !== "local" ? "Cloudflare R2" : "Local Storage",
};
}
+55
View File
@@ -0,0 +1,55 @@
export interface GradientPreset {
id: string;
name: string;
background: string;
titleStyle: string;
}
export const GRADIENT_PRESETS: Record<string, GradientPreset> = {
"gradient-1": {
id: "gradient-1",
name: "Cotton Candy",
background: "linear-gradient(to top, #fad0c4 0%, #ffd1ff 100%)",
titleStyle: "bg-gradient-to-r from-[#4158D0] to-[#C850C0] bg-clip-text text-transparent",
},
"gradient-2": {
id: "gradient-2",
name: "Emerald Sky",
background: "linear-gradient(180deg, #2af598 0%, #009efd 100%)",
titleStyle: "bg-gradient-to-r from-[#85FFBD] to-[#009efd] bg-clip-text text-transparent",
},
"gradient-3": {
id: "gradient-3",
name: "Warm Flame",
background: "linear-gradient(120deg, #f6d365 0%, #fda085 100%)",
titleStyle: "bg-gradient-to-r from-[#FBAB7E] to-[#F7CE68] bg-clip-text text-transparent",
},
"gradient-4": {
id: "gradient-4",
name: "Lavender Mist",
background: "linear-gradient(120deg, #e0c3fc 0%, #8ec5fc 100%)",
titleStyle: "bg-gradient-to-r from-[#FFDEE9] to-[#8ec5fc] bg-clip-text text-transparent",
},
"gradient-5": {
id: "gradient-5",
name: "Cyber Neon",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
titleStyle: "bg-gradient-to-r from-[#FF9A8B] to-[#FF6A88] bg-clip-text text-transparent",
},
"gradient-6": {
id: "gradient-6",
name: "Sunset Glow",
background: "linear-gradient(120deg, #89f7fe 0%, #66a6ff 100%)",
titleStyle: "bg-gradient-to-r from-[#FFE53B] to-[#FF2525] bg-clip-text text-transparent",
},
};
export function getBackgroundStyle(id?: string | null): string {
if (!id) return GRADIENT_PRESETS["gradient-1"].background;
return GRADIENT_PRESETS[id]?.background || GRADIENT_PRESETS["gradient-1"].background;
}
export function getTitleGradientClass(id?: string | null): string {
if (!id) return GRADIENT_PRESETS["gradient-1"].titleStyle;
return GRADIENT_PRESETS[id]?.titleStyle || GRADIENT_PRESETS["gradient-1"].titleStyle;
}
+45
View File
@@ -0,0 +1,45 @@
import * as cheerio from "cheerio";
/**
* Fetch and extract the page title from a URL.
*/
export async function fetchUrlTitle(targetUrl: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 6000);
const res = await fetch(targetUrl, {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
});
clearTimeout(timeout);
if (!res.ok) return null;
const html = await res.text();
const $ = cheerio.load(html);
// 1. Try standard title tag
let title = $("title").first().text().trim();
// 2. Fallback to OpenGraph title
if (!title) {
title = $('meta[property="og:title"]').attr("content")?.trim() || "";
}
// 3. Fallback to Twitter title
if (!title) {
title = $('meta[name="twitter:title"]').attr("content")?.trim() || "";
}
return title ? title.slice(0, 255) : null;
} catch (error) {
console.warn(`Could not fetch title for ${targetUrl}:`, error);
return null;
}
}