Initial commit

This commit is contained in:
2026-03-27 19:35:14 +01:00
commit 38581b88a4
68 changed files with 12137 additions and 0 deletions

72
lib/auth.ts Normal file
View File

@@ -0,0 +1,72 @@
import { PrismaAdapter } from "@auth/prisma-adapter";
import { compare } from "bcryptjs";
import { getServerSession, type NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { loginSchema } from "@/lib/validators/auth";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db),
secret:
process.env.NEXTAUTH_SECRET ??
(process.env.NODE_ENV === "development" ? "payme-dev-only-secret-change-in-production" : undefined),
session: {
// Credentials auth in NextAuth v4 requires JWT strategy.
strategy: "jwt"
},
pages: {
signIn: "/login"
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
authorize: async (credentials) => {
// Apply credential attempt throttling in front of this endpoint for production deployments.
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) {
return null;
}
const user = await db.user.findUnique({
where: { email: parsed.data.email }
});
if (!user) {
return null;
}
const validPassword = await compare(parsed.data.password, user.hashedPassword);
if (!validPassword) {
return null;
}
return {
id: user.id,
email: user.email
};
}
})
],
callbacks: {
jwt: ({ token, user }) => {
if (user) {
token.id = user.id;
}
return token;
},
session: ({ session, token }) => {
if (session.user) {
session.user.id = String(token.id ?? token.sub ?? "");
}
return session;
}
}
};
export function auth() {
return getServerSession(authOptions);
}

62
lib/constants.ts Normal file
View File

@@ -0,0 +1,62 @@
import { PaymentMethodType } from "@prisma/client";
export const DEFAULT_THEME_ID = "terminal-dark";
export const THEME_TOKENS: Record<
string,
{
bg: string;
panel: string;
text: string;
muted: string;
border: string;
accent: string;
}
> = {
"terminal-dark": {
bg: "11 12 14",
panel: "16 18 20",
text: "237 232 220",
muted: "163 156 140",
border: "52 55 58",
accent: "118 150 92"
},
"amber-paper": {
bg: "15 16 17",
panel: "20 22 24",
text: "238 229 212",
muted: "178 166 146",
border: "58 53 44",
accent: "166 129 74"
}
};
export const PAYMENT_METHOD_LABELS: Record<PaymentMethodType, string> = {
PAYPAL: "PayPal",
BITCOIN: "Bitcoin",
ETHEREUM: "Ethereum",
MONERO: "Monero",
LITECOIN: "Litecoin",
SOLANA: "Solana",
USDT: "USDT",
REVOLUT: "Revolut",
BANK_TRANSFER: "Bank Transfer",
CUSTOM: "Custom"
};
export const PAYMENT_METHOD_TYPES = Object.keys(PAYMENT_METHOD_LABELS) as PaymentMethodType[];
export const USDT_NETWORKS = ["ERC20", "TRC20", "BEP20", "SOL", "POLYGON"] as const;
export const SUPPORTS_QR = new Set<PaymentMethodType>([
"PAYPAL",
"BITCOIN",
"ETHEREUM",
"MONERO",
"LITECOIN",
"SOLANA",
"USDT",
"REVOLUT",
"BANK_TRANSFER",
"CUSTOM"
]);

11
lib/db.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
export const db = global.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
global.prisma = db;
}

47
lib/payment-uri.ts Normal file
View File

@@ -0,0 +1,47 @@
import { PaymentMethodType } from "@prisma/client";
function looksLikeUrl(value: string) {
try {
const url = new URL(value);
return ["http:", "https:"].includes(url.protocol);
} catch {
return false;
}
}
export function buildPaymentUri(type: PaymentMethodType, value: string, network?: string | null) {
const cleaned = value.trim();
switch (type) {
case "PAYPAL": {
if (looksLikeUrl(cleaned)) {
return cleaned;
}
return `https://paypal.me/${cleaned}`;
}
case "BITCOIN":
return `bitcoin:${cleaned}`;
case "ETHEREUM":
return `ethereum:${cleaned}`;
case "MONERO":
return `monero:${cleaned}`;
case "LITECOIN":
return `litecoin:${cleaned}`;
case "SOLANA":
return `solana:${cleaned}`;
case "USDT":
return network ? `usdt:${network}:${cleaned}` : `usdt:${cleaned}`;
case "REVOLUT": {
if (looksLikeUrl(cleaned)) {
return cleaned;
}
return `https://revolut.me/${cleaned}`;
}
case "BANK_TRANSFER":
return cleaned;
case "CUSTOM":
return cleaned;
default:
return cleaned;
}
}

6
lib/qr.ts Normal file
View File

@@ -0,0 +1,6 @@
import { PaymentMethodType } from "@prisma/client";
import { buildPaymentUri } from "@/lib/payment-uri";
export function getQrPayload(type: PaymentMethodType, value: string, network?: string | null) {
return buildPaymentUri(type, value, network);
}

36
lib/sanitize.ts Normal file
View File

@@ -0,0 +1,36 @@
export function sanitizePlainText(value: string) {
return value
.replace(/[\u0000-\u001F\u007F]/g, "")
.replace(/\s+/g, " ")
.trim();
}
export function sanitizeOptionalPlainText(value: string | null | undefined) {
if (!value) {
return undefined;
}
const sanitized = sanitizePlainText(value);
return sanitized.length > 0 ? sanitized : undefined;
}
export function normalizeUsername(value: string) {
return sanitizePlainText(value).toLowerCase().replace(/^@+/, "");
}
export function safeUrl(value: string | null | undefined) {
if (!value) {
return undefined;
}
const sanitized = value.trim();
try {
const url = new URL(sanitized);
if (!["http:", "https:"].includes(url.protocol)) {
return undefined;
}
return sanitized;
} catch {
return undefined;
}
}

30
lib/session.ts Normal file
View File

@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function getCurrentSession() {
return auth();
}
export async function requireSession() {
const session = await getCurrentSession();
if (!session?.user?.id) {
redirect("/login");
}
return session;
}
export async function requireCurrentUser() {
const session = await requireSession();
const user = await db.user.findUnique({
where: { id: session.user.id },
include: { profile: true }
});
if (!user) {
redirect("/login");
}
return user;
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

27
lib/validators/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { z } from "zod";
export const emailSchema = z.string().trim().toLowerCase().email().max(254);
export const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password must be at most 128 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number");
export const registerSchema = z.object({
email: emailSchema,
password: passwordSchema,
username: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9_]{3,32}$/, "Username must be 3-32 chars (a-z, 0-9, _)"),
displayName: z.string().trim().min(2).max(60)
});
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1)
});

View File

@@ -0,0 +1,134 @@
import { PaymentMethodType } from "@prisma/client";
import { z } from "zod";
import { USDT_NETWORKS } from "@/lib/constants";
const BTC_REGEX = /^(bc1[a-z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/;
const ETH_REGEX = /^0x[a-fA-F0-9]{40}$/;
const XMR_REGEX = /^(4|8)[1-9A-HJ-NP-Za-km-z]{94,105}$/;
const LTC_REGEX = /^(ltc1[a-z0-9]{39,59}|[LM3][a-km-zA-HJ-NP-Z1-9]{26,34})$/;
const SOL_REGEX = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
const PAYPAL_USERNAME_REGEX = /^[a-zA-Z0-9._-]{3,64}$/;
const REVOLUT_REGEX = /^[a-zA-Z0-9._-]{3,32}$/;
function isPayPalValue(value: string) {
if (PAYPAL_USERNAME_REGEX.test(value)) {
return true;
}
try {
const url = new URL(value);
return url.hostname.includes("paypal.com") || url.hostname.includes("paypal.me");
} catch {
return false;
}
}
function isRevolutValue(value: string) {
if (REVOLUT_REGEX.test(value)) {
return true;
}
try {
const url = new URL(value);
return url.hostname.includes("revolut.com") || url.hostname.includes("revolut.me");
} catch {
return false;
}
}
function isValidIban(value: string) {
const iban = value.replace(/\s+/g, "").toUpperCase();
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/.test(iban)) {
return false;
}
const rearranged = `${iban.slice(4)}${iban.slice(0, 4)}`;
let transformed = "";
for (const char of rearranged) {
const code = char.charCodeAt(0);
transformed += code >= 65 && code <= 90 ? String(code - 55) : char;
}
let remainder = 0;
for (const digit of transformed) {
remainder = (remainder * 10 + Number(digit)) % 97;
}
return remainder === 1;
}
export const paymentMethodSchema = z
.object({
id: z.string().optional(),
type: z.nativeEnum(PaymentMethodType),
label: z.string().trim().min(1).max(60),
value: z.string().trim().min(1).max(500),
network: z.string().trim().max(30).optional().or(z.literal("")),
description: z.string().trim().max(180).optional().or(z.literal("")),
isVisible: z.boolean().default(true)
})
.superRefine((input, ctx) => {
const value = input.value.trim();
const network = input.network?.trim();
if (input.type === "PAYPAL" && !isPayPalValue(value)) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "PayPal value must be a PayPal username or paypal.com/paypal.me URL"
});
}
if (input.type === "BITCOIN" && !BTC_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Bitcoin address format" });
}
if (input.type === "ETHEREUM" && !ETH_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Ethereum address format" });
}
if (input.type === "MONERO" && !XMR_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Monero address format" });
}
if (input.type === "LITECOIN" && !LTC_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Litecoin address format" });
}
if (input.type === "SOLANA" && !SOL_REGEX.test(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid Solana address format" });
}
if (input.type === "USDT") {
if (!network || !USDT_NETWORKS.includes(network as (typeof USDT_NETWORKS)[number])) {
ctx.addIssue({
code: "custom",
path: ["network"],
message: `USDT network must be one of: ${USDT_NETWORKS.join(", ")}`
});
}
const looksLikeWallet = ETH_REGEX.test(value) || SOL_REGEX.test(value) || /^[T][1-9A-HJ-NP-Za-km-z]{33}$/.test(value);
if (!looksLikeWallet) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Invalid USDT wallet address format for common networks"
});
}
}
if (input.type === "REVOLUT" && !isRevolutValue(value)) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Revolut value must be a username or revolut.com/revolut.me URL"
});
}
if (input.type === "BANK_TRANSFER" && !isValidIban(value)) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Invalid IBAN format" });
}
});
export type PaymentMethodInput = z.infer<typeof paymentMethodSchema>;

23
lib/validators/profile.ts Normal file
View File

@@ -0,0 +1,23 @@
import { z } from "zod";
export const profileSchema = z.object({
username: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9_]{3,32}$/, "Username must be 3-32 chars (a-z, 0-9, _)")
.max(32),
displayName: z.string().trim().min(2).max(60),
bio: z.string().trim().max(280).optional().or(z.literal("")),
avatarUrl: z
.string()
.trim()
.url("Avatar URL must be a valid URL")
.max(500)
.optional()
.or(z.literal("")),
themeId: z.string().trim().min(1),
isPublic: z.boolean()
});
export type ProfileInput = z.infer<typeof profileSchema>;

View File

@@ -0,0 +1,10 @@
import { z } from "zod";
export const socialLinkSchema = z.object({
id: z.string().optional(),
label: z.string().trim().min(2).max(50),
url: z.string().trim().url("Social link must be a valid URL").max(500),
isVisible: z.boolean().default(true)
});
export type SocialLinkInput = z.infer<typeof socialLinkSchema>;