search console SEO ableitungen

This commit is contained in:
2026-03-23 19:01:52 -05:00
parent d47108d27c
commit e6b19e7a1c
150 changed files with 26257 additions and 25909 deletions

View File

@@ -1,86 +1,86 @@
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,
plan: user.plan || 'FREE',
};
},
}),
...(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, trigger, session }) {
if (user) {
token.id = user.id;
token.plan = user.plan;
}
// Update session support
if (trigger === "update" && session?.plan) {
token.plan = session.plan;
}
return token;
},
async session({ session, token }) {
if (session?.user) {
session.user.id = token.id as string;
session.user.plan = (token.plan as string) || 'FREE';
}
return session;
},
},
pages: {
signIn: '/login',
error: '/login',
},
secret: process.env.NEXTAUTH_SECRET,
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,
plan: user.plan || 'FREE',
};
},
}),
...(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, trigger, session }) {
if (user) {
token.id = user.id;
token.plan = user.plan;
}
// Update session support
if (trigger === "update" && session?.plan) {
token.plan = session.plan;
}
return token;
},
async session({ session, token }) {
if (session?.user) {
session.user.id = token.id as string;
session.user.plan = (token.plan as string) || 'FREE';
}
return session;
},
},
pages: {
signIn: '/login',
error: '/login',
},
secret: process.env.NEXTAUTH_SECRET,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +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,
},
},
},
};
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,
},
},
},
};
}

View File

@@ -1,39 +1,39 @@
/**
* Cookie configuration helpers
* Automatically uses secure settings in production
*/
const isProduction = process.env.NODE_ENV === 'production';
/**
* Get cookie options for authentication cookies
*/
export function getAuthCookieOptions() {
return {
httpOnly: true,
secure: isProduction, // HTTPS only in production
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
};
}
/**
* Get cookie options for CSRF tokens
* Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
*/
export function getCsrfCookieOptions() {
return {
httpOnly: false, // Client needs to read this token for the header
secure: isProduction, // HTTPS only in production
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24, // 24 hours
path: '/', // Available on all paths
};
}
/**
* Check if running in production
*/
export function isProductionEnvironment(): boolean {
return isProduction;
}
/**
* Cookie configuration helpers
* Automatically uses secure settings in production
*/
const isProduction = process.env.NODE_ENV === 'production';
/**
* Get cookie options for authentication cookies
*/
export function getAuthCookieOptions() {
return {
httpOnly: true,
secure: isProduction, // HTTPS only in production
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
};
}
/**
* Get cookie options for CSRF tokens
* Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
*/
export function getCsrfCookieOptions() {
return {
httpOnly: false, // Client needs to read this token for the header
secure: isProduction, // HTTPS only in production
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24, // 24 hours
path: '/', // Available on all paths
};
}
/**
* Check if running in production
*/
export function isProductionEnvironment(): boolean {
return isProduction;
}

View File

@@ -1,79 +1,79 @@
import { cookies } from 'next/headers';
import { v4 as uuidv4 } from 'uuid';
import { getCsrfCookieOptions } from './cookieConfig';
const CSRF_TOKEN_COOKIE = 'csrf_token';
const CSRF_TOKEN_HEADER = 'x-csrf-token';
/**
* Generate a new CSRF token and set it as a cookie
*/
export function generateCsrfToken(): string {
const token = uuidv4();
cookies().set(CSRF_TOKEN_COOKIE, token, getCsrfCookieOptions());
return token;
}
/**
* Get the CSRF token from cookies
*/
export function getCsrfToken(): string | undefined {
return cookies().get(CSRF_TOKEN_COOKIE)?.value;
}
/**
* Validate CSRF token from request header against cookie
*/
export function validateCsrfToken(headerToken: string | null): boolean {
if (!headerToken) {
return false;
}
const cookieToken = getCsrfToken();
if (!cookieToken) {
return false;
}
// Constant-time comparison to prevent timing attacks
return cookieToken === headerToken;
}
/**
* CSRF Protection middleware for API routes
*/
export function csrfProtection(request: Request): { valid: boolean; error?: string } {
const method = request.method;
// Only protect state-changing methods
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
return { valid: true };
}
const headerToken = request.headers.get(CSRF_TOKEN_HEADER);
if (!validateCsrfToken(headerToken)) {
return {
valid: false,
error: 'Invalid or missing CSRF token'
};
}
return { valid: true };
}
/**
* Get CSRF token for client-side use
* This should be called from a GET endpoint
*/
export function getOrCreateCsrfToken(): string {
let token = getCsrfToken();
if (!token) {
token = generateCsrfToken();
}
return token;
}
import { cookies } from 'next/headers';
import { v4 as uuidv4 } from 'uuid';
import { getCsrfCookieOptions } from './cookieConfig';
const CSRF_TOKEN_COOKIE = 'csrf_token';
const CSRF_TOKEN_HEADER = 'x-csrf-token';
/**
* Generate a new CSRF token and set it as a cookie
*/
export function generateCsrfToken(): string {
const token = uuidv4();
cookies().set(CSRF_TOKEN_COOKIE, token, getCsrfCookieOptions());
return token;
}
/**
* Get the CSRF token from cookies
*/
export function getCsrfToken(): string | undefined {
return cookies().get(CSRF_TOKEN_COOKIE)?.value;
}
/**
* Validate CSRF token from request header against cookie
*/
export function validateCsrfToken(headerToken: string | null): boolean {
if (!headerToken) {
return false;
}
const cookieToken = getCsrfToken();
if (!cookieToken) {
return false;
}
// Constant-time comparison to prevent timing attacks
return cookieToken === headerToken;
}
/**
* CSRF Protection middleware for API routes
*/
export function csrfProtection(request: Request): { valid: boolean; error?: string } {
const method = request.method;
// Only protect state-changing methods
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
return { valid: true };
}
const headerToken = request.headers.get(CSRF_TOKEN_HEADER);
if (!validateCsrfToken(headerToken)) {
return {
valid: false,
error: 'Invalid or missing CSRF token'
};
}
return { valid: true };
}
/**
* Get CSRF token for client-side use
* This should be called from a GET endpoint
*/
export function getOrCreateCsrfToken(): string {
let token = getCsrfToken();
if (!token) {
token = generateCsrfToken();
}
return token;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,55 @@
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
// iPadOS 13+ sends "Macintosh" user agent.
// Without referrer info here, we fall back to checking for Safari-only Mac UAs (common for iPad)
const isIPad = /iPad/i.test(userAgent) ||
(/Macintosh/i.test(userAgent) && /Safari/i.test(userAgent) && !/Chrome/i.test(userAgent));
if (isIPad || /Tablet|PlayBook|Silk/i.test(userAgent)) {
device = 'tablet';
} else if (/Mobile|Android|iPhone/i.test(userAgent) && !isIPad) {
device = 'mobile';
} 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 };
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
// iPadOS 13+ sends "Macintosh" user agent.
// Without referrer info here, we fall back to checking for Safari-only Mac UAs (common for iPad)
const isIPad = /iPad/i.test(userAgent) ||
(/Macintosh/i.test(userAgent) && /Safari/i.test(userAgent) && !/Chrome/i.test(userAgent));
if (isIPad || /Tablet|PlayBook|Silk/i.test(userAgent)) {
device = 'tablet';
} else if (/Mobile|Android|iPhone/i.test(userAgent) && !isIPad) {
device = 'mobile';
} 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 };
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +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);
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);
}

View File

@@ -1,227 +1,227 @@
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(),
message: z.string().optional(),
text: z.string().optional(),
// VCARD fields
firstName: z.string().optional(),
lastName: z.string().optional(),
organization: z.string().optional(),
title: z.string().optional(),
// GEO fields
latitude: z.number().optional(),
longitude: z.number().optional(),
label: 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 '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 'VCARD':
return `BEGIN:VCARD
VERSION:3.0
FN:${content.firstName || ''} ${content.lastName || ''}
ORG:${content.organization || ''}
TITLE:${content.title || ''}
EMAIL:${content.email || ''}
TEL:${content.phone || ''}
END:VCARD`;
case 'GEO':
const lat = content.latitude || 0;
const lon = content.longitude || 0;
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
return `geo:${lat},${lon}${label}`;
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);
}
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(),
message: z.string().optional(),
text: z.string().optional(),
// VCARD fields
firstName: z.string().optional(),
lastName: z.string().optional(),
organization: z.string().optional(),
title: z.string().optional(),
// GEO fields
latitude: z.number().optional(),
longitude: z.number().optional(),
label: 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 '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 'VCARD':
return `BEGIN:VCARD
VERSION:3.0
FN:${content.firstName || ''} ${content.lastName || ''}
ORG:${content.organization || ''}
TITLE:${content.title || ''}
EMAIL:${content.email || ''}
TEL:${content.phone || ''}
END:VCARD`;
case 'GEO':
const lat = content.latitude || 0;
const lon = content.longitude || 0;
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
return `geo:${lat},${lon}${label}`;
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);
}
}

View File

@@ -1,229 +1,229 @@
/**
* Simple in-memory rate limiter
* For production with multiple servers, consider using Redis/Upstash
*/
interface RateLimitEntry {
count: number;
resetAt: number;
}
// Store rate limit data in memory
const rateLimitStore = new Map<string, RateLimitEntry>();
// Cleanup old entries every 5 minutes
setInterval(() => {
const now = Date.now();
const entries = Array.from(rateLimitStore.entries());
for (const [key, entry] of entries) {
if (entry.resetAt < now) {
rateLimitStore.delete(key);
}
}
}, 5 * 60 * 1000);
export interface RateLimitConfig {
/**
* Maximum number of requests allowed in the window
*/
maxRequests: number;
/**
* Time window in seconds
*/
windowSeconds: number;
/**
* Unique identifier for this rate limiter (e.g., 'login', 'signup')
*/
name: string;
}
export interface RateLimitResult {
success: boolean;
limit: number;
remaining: number;
reset: number;
}
/**
* Check if a request should be rate limited
*
* @param identifier - Unique identifier for the client (e.g., IP address, email)
* @param config - Rate limit configuration
* @returns RateLimitResult
*/
export function rateLimit(
identifier: string,
config: RateLimitConfig
): RateLimitResult {
const key = `${config.name}:${identifier}`;
const now = Date.now();
const windowMs = config.windowSeconds * 1000;
let entry = rateLimitStore.get(key);
// Create new entry if doesn't exist or expired
if (!entry || entry.resetAt < now) {
entry = {
count: 0,
resetAt: now + windowMs,
};
rateLimitStore.set(key, entry);
}
// Increment counter
entry.count++;
const remaining = Math.max(0, config.maxRequests - entry.count);
const success = entry.count <= config.maxRequests;
return {
success,
limit: config.maxRequests,
remaining,
reset: entry.resetAt,
};
}
/**
* Get client identifier from request (IP address)
*/
export function getClientIdentifier(request: Request): string {
// Try to get real IP from headers (for proxies/load balancers)
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
}
const realIp = request.headers.get('x-real-ip');
if (realIp) {
return realIp;
}
// Fallback (this won't work well in production behind a proxy)
return 'unknown';
}
/**
* Predefined rate limit configurations
*/
export const RateLimits = {
// Auth endpoints
// Login: 5 attempts per 15 minutes
LOGIN: {
name: 'login',
maxRequests: 5,
windowSeconds: 15 * 60,
},
// Signup: 3 accounts per hour
SIGNUP: {
name: 'signup',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// Password reset: 3 attempts per hour
PASSWORD_RESET: {
name: 'password-reset',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// QR Code endpoints
// Create QR: 20 per minute
QR_CREATE: {
name: 'qr-create',
maxRequests: 20,
windowSeconds: 60,
},
// Modify QR: 30 per minute
QR_MODIFY: {
name: 'qr-modify',
maxRequests: 30,
windowSeconds: 60,
},
// Delete all QRs: 3 per hour
QR_DELETE_ALL: {
name: 'qr-delete-all',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// Bulk create: 3 per hour
BULK_CREATE: {
name: 'bulk-create',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// User settings endpoints
// Profile update: 10 per minute
PROFILE_UPDATE: {
name: 'profile-update',
maxRequests: 10,
windowSeconds: 60,
},
// Password change: 5 per hour
PASSWORD_CHANGE: {
name: 'password-change',
maxRequests: 5,
windowSeconds: 60 * 60,
},
// Account delete: 2 per day
ACCOUNT_DELETE: {
name: 'account-delete',
maxRequests: 2,
windowSeconds: 24 * 60 * 60,
},
// Analytics endpoints
// Analytics summary: 30 per minute
ANALYTICS: {
name: 'analytics',
maxRequests: 30,
windowSeconds: 60,
},
// Stripe endpoints
// Checkout session: 5 per minute
STRIPE_CHECKOUT: {
name: 'stripe-checkout',
maxRequests: 5,
windowSeconds: 60,
},
// Customer portal: 10 per minute
STRIPE_PORTAL: {
name: 'stripe-portal',
maxRequests: 10,
windowSeconds: 60,
},
// Cancel subscription: 3 per hour
STRIPE_CANCEL: {
name: 'stripe-cancel',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// Newsletter endpoints
// Newsletter subscribe: 5 per hour (prevent spam)
NEWSLETTER_SUBSCRIBE: {
name: 'newsletter-subscribe',
maxRequests: 5,
windowSeconds: 60 * 60,
},
// General API: 100 requests per minute
API: {
name: 'api',
maxRequests: 100,
windowSeconds: 60,
},
};
/**
* Simple in-memory rate limiter
* For production with multiple servers, consider using Redis/Upstash
*/
interface RateLimitEntry {
count: number;
resetAt: number;
}
// Store rate limit data in memory
const rateLimitStore = new Map<string, RateLimitEntry>();
// Cleanup old entries every 5 minutes
setInterval(() => {
const now = Date.now();
const entries = Array.from(rateLimitStore.entries());
for (const [key, entry] of entries) {
if (entry.resetAt < now) {
rateLimitStore.delete(key);
}
}
}, 5 * 60 * 1000);
export interface RateLimitConfig {
/**
* Maximum number of requests allowed in the window
*/
maxRequests: number;
/**
* Time window in seconds
*/
windowSeconds: number;
/**
* Unique identifier for this rate limiter (e.g., 'login', 'signup')
*/
name: string;
}
export interface RateLimitResult {
success: boolean;
limit: number;
remaining: number;
reset: number;
}
/**
* Check if a request should be rate limited
*
* @param identifier - Unique identifier for the client (e.g., IP address, email)
* @param config - Rate limit configuration
* @returns RateLimitResult
*/
export function rateLimit(
identifier: string,
config: RateLimitConfig
): RateLimitResult {
const key = `${config.name}:${identifier}`;
const now = Date.now();
const windowMs = config.windowSeconds * 1000;
let entry = rateLimitStore.get(key);
// Create new entry if doesn't exist or expired
if (!entry || entry.resetAt < now) {
entry = {
count: 0,
resetAt: now + windowMs,
};
rateLimitStore.set(key, entry);
}
// Increment counter
entry.count++;
const remaining = Math.max(0, config.maxRequests - entry.count);
const success = entry.count <= config.maxRequests;
return {
success,
limit: config.maxRequests,
remaining,
reset: entry.resetAt,
};
}
/**
* Get client identifier from request (IP address)
*/
export function getClientIdentifier(request: Request): string {
// Try to get real IP from headers (for proxies/load balancers)
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
}
const realIp = request.headers.get('x-real-ip');
if (realIp) {
return realIp;
}
// Fallback (this won't work well in production behind a proxy)
return 'unknown';
}
/**
* Predefined rate limit configurations
*/
export const RateLimits = {
// Auth endpoints
// Login: 5 attempts per 15 minutes
LOGIN: {
name: 'login',
maxRequests: 5,
windowSeconds: 15 * 60,
},
// Signup: 3 accounts per hour
SIGNUP: {
name: 'signup',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// Password reset: 3 attempts per hour
PASSWORD_RESET: {
name: 'password-reset',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// QR Code endpoints
// Create QR: 20 per minute
QR_CREATE: {
name: 'qr-create',
maxRequests: 20,
windowSeconds: 60,
},
// Modify QR: 30 per minute
QR_MODIFY: {
name: 'qr-modify',
maxRequests: 30,
windowSeconds: 60,
},
// Delete all QRs: 3 per hour
QR_DELETE_ALL: {
name: 'qr-delete-all',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// Bulk create: 3 per hour
BULK_CREATE: {
name: 'bulk-create',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// User settings endpoints
// Profile update: 10 per minute
PROFILE_UPDATE: {
name: 'profile-update',
maxRequests: 10,
windowSeconds: 60,
},
// Password change: 5 per hour
PASSWORD_CHANGE: {
name: 'password-change',
maxRequests: 5,
windowSeconds: 60 * 60,
},
// Account delete: 2 per day
ACCOUNT_DELETE: {
name: 'account-delete',
maxRequests: 2,
windowSeconds: 24 * 60 * 60,
},
// Analytics endpoints
// Analytics summary: 30 per minute
ANALYTICS: {
name: 'analytics',
maxRequests: 30,
windowSeconds: 60,
},
// Stripe endpoints
// Checkout session: 5 per minute
STRIPE_CHECKOUT: {
name: 'stripe-checkout',
maxRequests: 5,
windowSeconds: 60,
},
// Customer portal: 10 per minute
STRIPE_PORTAL: {
name: 'stripe-portal',
maxRequests: 10,
windowSeconds: 60,
},
// Cancel subscription: 3 per hour
STRIPE_CANCEL: {
name: 'stripe-cancel',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// Newsletter endpoints
// Newsletter subscribe: 5 per hour (prevent spam)
NEWSLETTER_SUBSCRIBE: {
name: 'newsletter-subscribe',
maxRequests: 5,
windowSeconds: 60 * 60,
},
// General API: 100 requests per minute
API: {
name: 'api',
maxRequests: 100,
windowSeconds: 60,
},
};

View File

@@ -1,80 +1,80 @@
import Stripe from 'stripe';
// Use a placeholder during build time, real key at runtime
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
export const stripe = new Stripe(stripeKey, {
apiVersion: '2025-10-29.clover',
typescript: true,
});
// Runtime validation (will throw when actually used in production if not set)
export function validateStripeKey() {
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set');
}
}
export const STRIPE_PLANS = {
FREE: {
name: 'Free / Starter',
price: 0,
currency: 'EUR',
interval: 'month',
features: [
'3 dynamische QR-Codes',
'Basis-Tracking (Scans + Standort)',
'Einfache Designs',
'Unbegrenzte statische QR-Codes',
],
limits: {
dynamicQRCodes: 3,
staticQRCodes: -1, // unlimited
teamMembers: 1,
},
priceId: null, // No Stripe price for free plan
},
PRO: {
name: 'Pro',
price: 9,
priceYearly: 90,
currency: 'EUR',
interval: 'month',
features: [
'50 QR Codes',
'Branding (Colors)',
'Detailed Analytics (Date, Device, City)',
'CSV Export',
'SVG/PNG Download',
],
limits: {
dynamicQRCodes: 50,
staticQRCodes: -1,
teamMembers: 1,
},
priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY,
priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY,
},
BUSINESS: {
name: 'Business',
price: 29,
priceYearly: 290,
currency: 'EUR',
interval: 'month',
features: [
'500 QR-Codes',
'Everything from Pro',
'Bulk QR Generation (up to 1,000)',
'Priority Support',
],
limits: {
dynamicQRCodes: 500,
staticQRCodes: -1,
teamMembers: 1,
},
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
},
} as const;
export type PlanType = keyof typeof STRIPE_PLANS;
import Stripe from 'stripe';
// Use a placeholder during build time, real key at runtime
const stripeKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder_for_build';
export const stripe = new Stripe(stripeKey, {
apiVersion: '2025-10-29.clover',
typescript: true,
});
// Runtime validation (will throw when actually used in production if not set)
export function validateStripeKey() {
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set');
}
}
export const STRIPE_PLANS = {
FREE: {
name: 'Free / Starter',
price: 0,
currency: 'EUR',
interval: 'month',
features: [
'3 dynamische QR-Codes',
'Basis-Tracking (Scans + Standort)',
'Einfache Designs',
'Unbegrenzte statische QR-Codes',
],
limits: {
dynamicQRCodes: 3,
staticQRCodes: -1, // unlimited
teamMembers: 1,
},
priceId: null, // No Stripe price for free plan
},
PRO: {
name: 'Pro',
price: 9,
priceYearly: 90,
currency: 'EUR',
interval: 'month',
features: [
'50 QR Codes',
'Branding (Colors)',
'Detailed Analytics (Date, Device, City)',
'CSV Export',
'SVG/PNG Download',
],
limits: {
dynamicQRCodes: 50,
staticQRCodes: -1,
teamMembers: 1,
},
priceId: process.env.STRIPE_PRICE_ID_PRO_MONTHLY,
priceIdYearly: process.env.STRIPE_PRICE_ID_PRO_YEARLY,
},
BUSINESS: {
name: 'Business',
price: 29,
priceYearly: 290,
currency: 'EUR',
interval: 'month',
features: [
'500 QR-Codes',
'Everything from Pro',
'Bulk QR Generation (up to 1,000)',
'Priority Support',
],
limits: {
dynamicQRCodes: 500,
staticQRCodes: -1,
teamMembers: 1,
},
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
},
} as const;
export type PlanType = keyof typeof STRIPE_PLANS;

View File

@@ -1,58 +1,58 @@
export type Testimonial = {
id: string;
rating: number;
title: string;
content: string;
author: {
name: string;
location?: string;
company?: string;
role?: string;
};
date: string;
datePublished: string;
verified: boolean;
featured: boolean;
useCase?: string;
};
export type AggregateRating = {
ratingValue: number;
reviewCount: number;
bestRating: number;
worstRating: number;
};
export const testimonials: Testimonial[] = [
{
id: "pottery-claudia-knuth-001",
rating: 5,
title: "Perfect for my pottery",
content: "I use QR-Master for my pottery as a link to my homepage and as a digital business card. I place the codes directly on my pottery pieces so interested customers can instantly access my website. Reliable and practical a great solution!",
author: {
name: "Claudia",
company: "Hotshpotsh",
location: "Texas"
},
date: "January 2026",
datePublished: "2026-01-15T00:00:00Z",
verified: true,
featured: true,
useCase: "pottery"
}
];
export function getAggregateRating(): AggregateRating {
const ratings = testimonials.map(t => t.rating);
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;
return {
ratingValue: Number(avgRating.toFixed(1)),
reviewCount: testimonials.length,
bestRating: 5,
worstRating: 1
};
}
export function getFeaturedTestimonials(): Testimonial[] {
return testimonials.filter(t => t.featured);
}
export type Testimonial = {
id: string;
rating: number;
title: string;
content: string;
author: {
name: string;
location?: string;
company?: string;
role?: string;
};
date: string;
datePublished: string;
verified: boolean;
featured: boolean;
useCase?: string;
};
export type AggregateRating = {
ratingValue: number;
reviewCount: number;
bestRating: number;
worstRating: number;
};
export const testimonials: Testimonial[] = [
{
id: "pottery-claudia-knuth-001",
rating: 5,
title: "Perfect for my pottery",
content: "I use QR-Master for my pottery as a link to my homepage and as a digital business card. I place the codes directly on my pottery pieces so interested customers can instantly access my website. Reliable and practical a great solution!",
author: {
name: "Claudia",
company: "Hotshpotsh",
location: "Texas"
},
date: "January 2026",
datePublished: "2026-01-15T00:00:00Z",
verified: true,
featured: true,
useCase: "pottery"
}
];
export function getAggregateRating(): AggregateRating {
const ratings = testimonials.map(t => t.rating);
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;
return {
ratingValue: Number(avgRating.toFixed(1)),
reviewCount: testimonials.length,
bestRating: 5,
worstRating: 1
};
}
export function getFeaturedTestimonials(): Testimonial[] {
return testimonials.filter(t => t.featured);
}