Initial commit
This commit is contained in:
72
lib/auth.ts
Normal file
72
lib/auth.ts
Normal 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
62
lib/constants.ts
Normal 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
11
lib/db.ts
Normal 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
47
lib/payment-uri.ts
Normal 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
6
lib/qr.ts
Normal 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
36
lib/sanitize.ts
Normal 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
30
lib/session.ts
Normal 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
6
lib/utils.ts
Normal 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
27
lib/validators/auth.ts
Normal 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)
|
||||
});
|
||||
134
lib/validators/payment-method.ts
Normal file
134
lib/validators/payment-method.ts
Normal 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
23
lib/validators/profile.ts
Normal 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>;
|
||||
10
lib/validators/social-link.ts
Normal file
10
lib/validators/social-link.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user