Initial commit - QR Master application
This commit is contained in:
77
src/lib/auth.ts
Normal file
77
src/lib/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { db } from './db';
|
||||
import { comparePassword } from './hash';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(db) as any,
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await comparePassword(
|
||||
credentials.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
},
|
||||
}),
|
||||
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||
? [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session?.user) {
|
||||
session.user.id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
error: '/login',
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
};
|
||||
80
src/lib/charts.ts
Normal file
80
src/lib/charts.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ChartConfiguration } from 'chart.js';
|
||||
|
||||
export const defaultChartOptions: Partial<ChartConfiguration['options']> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#f3f4f6',
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function createLineChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data,
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...defaultChartOptions,
|
||||
plugins: {
|
||||
...(defaultChartOptions?.plugins || {}),
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBarChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
data,
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...defaultChartOptions,
|
||||
plugins: {
|
||||
...(defaultChartOptions?.plugins || {}),
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
13
src/lib/db.ts
Normal file
13
src/lib/db.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const db =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: ['query'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
||||
27
src/lib/env.ts
Normal file
27
src/lib/env.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().default('3000'),
|
||||
DATABASE_URL: z.string().default('postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public'),
|
||||
NEXTAUTH_URL: z.string().default('http://localhost:3050'),
|
||||
NEXTAUTH_SECRET: z.string().default('development-secret-change-in-production'),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
REDIS_URL: z.string().optional(),
|
||||
IP_SALT: z.string().default('development-salt-change-in-production'),
|
||||
ENABLE_DEMO: z.string().default('false'),
|
||||
});
|
||||
|
||||
// During build, we might not have all env vars, so we'll use defaults
|
||||
const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build' || !process.env.DATABASE_URL;
|
||||
|
||||
export const env = isBuildTime
|
||||
? envSchema.parse({
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@db:5432/qrmaster?schema=public',
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3050',
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'development-secret-change-in-production',
|
||||
IP_SALT: process.env.IP_SALT || 'development-salt-change-in-production',
|
||||
})
|
||||
: envSchema.parse(process.env);
|
||||
50
src/lib/geo.ts
Normal file
50
src/lib/geo.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export function getCountryFromHeaders(headers: Headers): string | null {
|
||||
// Try Vercel's country header first
|
||||
const vercelCountry = headers.get('x-vercel-ip-country');
|
||||
if (vercelCountry) {
|
||||
return vercelCountry;
|
||||
}
|
||||
|
||||
// Try Cloudflare's country header
|
||||
const cfCountry = headers.get('cf-ipcountry');
|
||||
if (cfCountry && cfCountry !== 'XX') {
|
||||
return cfCountry;
|
||||
}
|
||||
|
||||
// Fallback to other common headers
|
||||
const country = headers.get('x-country-code') || headers.get('x-forwarded-country');
|
||||
return country || null;
|
||||
}
|
||||
|
||||
export function parseUserAgent(userAgent: string | null): { device: string | null; os: string | null } {
|
||||
if (!userAgent) {
|
||||
return { device: null, os: null };
|
||||
}
|
||||
|
||||
let device: string | null = null;
|
||||
let os: string | null = null;
|
||||
|
||||
// Detect device
|
||||
if (/Mobile|Android|iPhone|iPad/.test(userAgent)) {
|
||||
device = 'mobile';
|
||||
} else if (/Tablet|iPad/.test(userAgent)) {
|
||||
device = 'tablet';
|
||||
} else {
|
||||
device = 'desktop';
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
if (/Windows/.test(userAgent)) {
|
||||
os = 'Windows';
|
||||
} else if (/Mac OS X|macOS/.test(userAgent)) {
|
||||
os = 'macOS';
|
||||
} else if (/Linux/.test(userAgent)) {
|
||||
os = 'Linux';
|
||||
} else if (/Android/.test(userAgent)) {
|
||||
os = 'Android';
|
||||
} else if (/iOS|iPhone|iPad/.test(userAgent)) {
|
||||
os = 'iOS';
|
||||
}
|
||||
|
||||
return { device, os };
|
||||
}
|
||||
49
src/lib/hash.ts
Normal file
49
src/lib/hash.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import crypto from 'crypto';
|
||||
import { env } from './env';
|
||||
|
||||
/**
|
||||
* Hash an IP address for privacy
|
||||
* Uses a salt from environment variables to ensure consistent hashing
|
||||
*/
|
||||
export function hashIP(ip: string): string {
|
||||
const salt = env.IP_SALT || 'default-salt-change-in-production';
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(ip + salt)
|
||||
.digest('hex')
|
||||
.substring(0, 16); // Use first 16 chars for storage efficiency
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random slug for QR codes
|
||||
*/
|
||||
export function generateSlug(title?: string): string {
|
||||
const base = title
|
||||
? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20)
|
||||
: 'qr';
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `${base}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure API key
|
||||
*/
|
||||
export function generateApiKey(): string {
|
||||
return 'qrm_' + crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password (for comparison with bcrypt hashed passwords)
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const bcrypt = await import('bcryptjs');
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a plain password with a hashed password
|
||||
*/
|
||||
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||
const bcrypt = await import('bcryptjs');
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
224
src/lib/qr.ts
Normal file
224
src/lib/qr.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { z } from 'zod';
|
||||
import QRCode from 'qrcode';
|
||||
import { db } from './db';
|
||||
import { generateSlug, hashIP } from './hash';
|
||||
import { getCountryFromHeaders, parseUserAgent } from './geo';
|
||||
import { ContentType, QRType, QRStatus } from '@prisma/client';
|
||||
import Redis from 'ioredis';
|
||||
import { env } from './env';
|
||||
|
||||
// Redis client (optional)
|
||||
let redis: Redis | null = null;
|
||||
if (env.REDIS_URL) {
|
||||
try {
|
||||
redis = new Redis(env.REDIS_URL);
|
||||
} catch (error) {
|
||||
console.warn('Redis connection failed, falling back to direct DB writes');
|
||||
}
|
||||
}
|
||||
|
||||
// Validation schemas
|
||||
const qrContentSchema = z.object({
|
||||
url: z.string().url().optional(),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
subject: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
ssid: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
security: z.enum(['WPA', 'WEP', 'nopass']).optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
organization: z.string().optional(),
|
||||
});
|
||||
|
||||
const qrStyleSchema = z.object({
|
||||
foregroundColor: z.string().default('#000000'),
|
||||
backgroundColor: z.string().default('#FFFFFF'),
|
||||
cornerStyle: z.enum(['square', 'rounded']).default('square'),
|
||||
size: z.number().min(100).max(1000).default(200),
|
||||
});
|
||||
|
||||
const createQRSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
type: z.nativeEnum(QRType).default(QRType.DYNAMIC),
|
||||
contentType: z.nativeEnum(ContentType).default(ContentType.URL),
|
||||
content: qrContentSchema,
|
||||
tags: z.array(z.string()).default([]),
|
||||
style: qrStyleSchema.default({}),
|
||||
});
|
||||
|
||||
export async function createQR(userId: string, data: z.infer<typeof createQRSchema>) {
|
||||
const validated = createQRSchema.parse(data);
|
||||
|
||||
const slug = generateSlug(validated.title);
|
||||
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: validated.title,
|
||||
type: validated.type,
|
||||
contentType: validated.contentType,
|
||||
content: validated.content,
|
||||
tags: validated.tags,
|
||||
style: validated.style,
|
||||
slug,
|
||||
status: QRStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
return qrCode;
|
||||
}
|
||||
|
||||
export async function updateQR(id: string, userId: string, data: Partial<z.infer<typeof createQRSchema>>) {
|
||||
const qrCode = await db.qRCode.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!qrCode) {
|
||||
throw new Error('QR Code not found');
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (data.title) updateData.title = data.title;
|
||||
if (data.content) updateData.content = data.content;
|
||||
if (data.tags) updateData.tags = data.tags;
|
||||
if (data.style) updateData.style = data.style;
|
||||
|
||||
return db.qRCode.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateQRCodeSVG(content: string, style: any = {}): Promise<string> {
|
||||
const options = {
|
||||
type: 'svg' as const,
|
||||
width: style.size || 200,
|
||||
color: {
|
||||
dark: style.foregroundColor || '#000000',
|
||||
light: style.backgroundColor || '#FFFFFF',
|
||||
},
|
||||
margin: 2,
|
||||
};
|
||||
|
||||
return QRCode.toString(content, options);
|
||||
}
|
||||
|
||||
export async function generateQRCodePNG(content: string, style: any = {}): Promise<Buffer> {
|
||||
const options = {
|
||||
width: style.size || 200,
|
||||
color: {
|
||||
dark: style.foregroundColor || '#000000',
|
||||
light: style.backgroundColor || '#FFFFFF',
|
||||
},
|
||||
margin: 2,
|
||||
};
|
||||
|
||||
return QRCode.toBuffer(content, options);
|
||||
}
|
||||
|
||||
export function getQRContent(qr: any): string {
|
||||
const { contentType, content } = qr;
|
||||
|
||||
switch (contentType) {
|
||||
case 'URL':
|
||||
return content.url || '';
|
||||
case 'PHONE':
|
||||
return `tel:${content.phone || ''}`;
|
||||
case 'EMAIL':
|
||||
const subject = content.subject ? `?subject=${encodeURIComponent(content.subject)}` : '';
|
||||
return `mailto:${content.email || ''}${subject}`;
|
||||
case 'SMS':
|
||||
const message = content.message ? `?body=${encodeURIComponent(content.message)}` : '';
|
||||
return `sms:${content.phone || ''}${message}`;
|
||||
case 'WHATSAPP':
|
||||
const whatsappMessage = content.message ? `?text=${encodeURIComponent(content.message)}` : '';
|
||||
return `https://wa.me/${content.phone || ''}${whatsappMessage}`;
|
||||
case 'WIFI':
|
||||
return `WIFI:T:${content.security || 'WPA'};S:${content.ssid || ''};P:${content.password || ''};;`;
|
||||
case 'VCARD':
|
||||
return `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${content.firstName || ''} ${content.lastName || ''}
|
||||
ORG:${content.organization || ''}
|
||||
EMAIL:${content.email || ''}
|
||||
TEL:${content.phone || ''}
|
||||
END:VCARD`;
|
||||
case 'TEXT':
|
||||
return content.text || '';
|
||||
default:
|
||||
return content.url || '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackScan(qrId: string, request: Request) {
|
||||
const headers = request.headers;
|
||||
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1';
|
||||
const userAgent = headers.get('user-agent');
|
||||
const referrer = headers.get('referer');
|
||||
const dnt = headers.get('dnt');
|
||||
|
||||
// Respect Do Not Track
|
||||
if (dnt === '1') {
|
||||
// Only increment aggregate counter, skip detailed tracking
|
||||
return;
|
||||
}
|
||||
|
||||
const ipHash = hashIP(ip);
|
||||
const country = getCountryFromHeaders(headers);
|
||||
const { device, os } = parseUserAgent(userAgent);
|
||||
|
||||
// Parse UTM parameters from referrer
|
||||
let utmSource: string | null = null;
|
||||
let utmMedium: string | null = null;
|
||||
let utmCampaign: string | null = null;
|
||||
|
||||
if (referrer) {
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
utmSource = url.searchParams.get('utm_source');
|
||||
utmMedium = url.searchParams.get('utm_medium');
|
||||
utmCampaign = url.searchParams.get('utm_campaign');
|
||||
} catch (e) {
|
||||
// Invalid referrer URL
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a unique scan (same IP hash within 24 hours)
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const existingScan = await db.qRScan.findFirst({
|
||||
where: {
|
||||
qrId,
|
||||
ipHash,
|
||||
ts: { gte: dayAgo },
|
||||
},
|
||||
});
|
||||
|
||||
const isUnique = !existingScan;
|
||||
|
||||
const scanData = {
|
||||
qrId,
|
||||
ipHash,
|
||||
userAgent,
|
||||
device,
|
||||
os,
|
||||
country,
|
||||
referrer,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
isUnique,
|
||||
};
|
||||
|
||||
// Fire-and-forget tracking
|
||||
if (redis) {
|
||||
// Queue to Redis for background processing
|
||||
redis.lpush('qr_scans', JSON.stringify(scanData)).catch(console.error);
|
||||
} else {
|
||||
// Direct database write
|
||||
db.qRScan.create({ data: scanData }).catch(console.error);
|
||||
}
|
||||
}
|
||||
67
src/lib/utils.ts
Normal file
67
src/lib/utils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
type ClassValue = string | number | null | undefined | boolean | ClassValue[] | { [key: string]: any };
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateContrast(hex1: string, hex2: string): number {
|
||||
// Convert hex to RGB
|
||||
const getRGB = (hex: string) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b];
|
||||
};
|
||||
|
||||
// Calculate relative luminance
|
||||
const getLuminance = (rgb: number[]) => {
|
||||
const [r, g, b] = rgb.map(c => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
|
||||
const rgb1 = getRGB(hex1);
|
||||
const rgb2 = getRGB(hex2);
|
||||
const lum1 = getLuminance(rgb1);
|
||||
const lum2 = getLuminance(rgb2);
|
||||
|
||||
const brightest = Math.max(lum1, lum2);
|
||||
const darkest = Math.min(lum1, lum2);
|
||||
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
}
|
||||
Reference in New Issue
Block a user