Initial commit - QR Master application

This commit is contained in:
Timo Knuth
2025-10-13 20:19:18 +02:00
commit 5262f9e78f
96 changed files with 18902 additions and 0 deletions

77
src/lib/auth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}