search console SEO ableitungen
This commit is contained in:
170
src/lib/auth.ts
170
src/lib/auth.ts
@@ -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,
|
||||
};
|
||||
5084
src/lib/blog-data.ts
5084
src/lib/blog-data.ts
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
158
src/lib/csrf.ts
158
src/lib/csrf.ts
@@ -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;
|
||||
}
|
||||
|
||||
1068
src/lib/email.ts
1068
src/lib/email.ts
File diff suppressed because it is too large
Load Diff
108
src/lib/geo.ts
108
src/lib/geo.ts
@@ -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
@@ -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);
|
||||
}
|
||||
452
src/lib/qr.ts
452
src/lib/qr.ts
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user