feat: initial commit - EZBio Next.js 16 fullstack URL shortener & bio link
This commit is contained in:
commit
569c10342f
64 files changed
+15670
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user