fantecy
This commit is contained in:
10
.env
10
.env
@@ -1,12 +1,18 @@
|
||||
# Database
|
||||
DATABASE_URL="postgres://postgres:8R5zeQo6zh1hSfUhbLwttepTB78TT9bZ5b1LF88jUbrGUiGg4YwWii6V1VG8XXWe@65.109.214.67:6060/football"
|
||||
DATABASE_URL="postgres://postgres:keBinNhpCirjUnVMSYkRBAXpY1OeeyqgBpS1Dw66tmKXBr38CypdPZAeaNlHpGyc@62.3.14.124:6066/postgres"
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-secret-key-change-this-in-production-min-32-chars"
|
||||
NEXTAUTH_SECRET="DRMESTAFOOTballd772hggdsgdas123132312321"
|
||||
|
||||
# Zarinpal
|
||||
ZARINPAL_MERCHANT_ID="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
|
||||
# IPPanel
|
||||
IPPANEL_PATTERN_CODE="bhuf3xmo7k80uhr"
|
||||
IPPANEL_TOKEN="YTFiNmYzM2MtNTJkNi00ZTFjLTljZDItMDU4YjQwODNiOGU1YWZlNzIwZDBlYTM0Yzg4M2IyMzMzMmRiM2E4ZTVjNTM="
|
||||
IPPANEL_ORIGINATOR="+983000505"
|
||||
IPPANEL_BASE_URL="https://edge.ippanel.com/v1"
|
||||
|
||||
# Node Environment
|
||||
NODE_ENV="development"
|
||||
|
||||
68
app/api/auth/mobile/request-code/route.ts
Normal file
68
app/api/auth/mobile/request-code/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db } from "@/lib/db";
|
||||
import { sendLoginCode } from "@/lib/ippanel";
|
||||
|
||||
const IRAN_MOBILE_REGEX = /^(\+98|98|0)?9\d{9}$/;
|
||||
|
||||
function normalizeIranMobile(phone: string) {
|
||||
const digits = phone.replace(/[^\d+]/g, "");
|
||||
if (!IRAN_MOBILE_REGEX.test(digits)) return null;
|
||||
|
||||
if (digits.startsWith("+98")) return `0${digits.slice(3)}`;
|
||||
if (digits.startsWith("98")) return `0${digits.slice(2)}`;
|
||||
if (digits.startsWith("9")) return `0${digits}`;
|
||||
return digits;
|
||||
}
|
||||
|
||||
function generateOtpCode() {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { phone } = await req.json().catch(() => ({}));
|
||||
const normalizedPhone = typeof phone === "string" ? normalizeIranMobile(phone) : null;
|
||||
|
||||
if (!normalizedPhone) {
|
||||
return NextResponse.json({ error: "شماره موبایل معتبر نیست" }, { status: 400 });
|
||||
}
|
||||
|
||||
const recentOtp = await db.loginOtp.findFirst({
|
||||
where: {
|
||||
phone: normalizedPhone,
|
||||
consumedAt: null,
|
||||
createdAt: { gte: new Date(Date.now() - 60 * 1000) },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (recentOtp) {
|
||||
return NextResponse.json(
|
||||
{ error: "برای ارسال کد جدید حداقل ۶۰ ثانیه صبر کنید" },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const code = generateOtpCode();
|
||||
const codeHash = await bcrypt.hash(code, 10);
|
||||
|
||||
await db.loginOtp.create({
|
||||
data: {
|
||||
phone: normalizedPhone,
|
||||
codeHash,
|
||||
expiresAt: new Date(Date.now() + 2 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await sendLoginCode(normalizedPhone, code);
|
||||
} catch (error) {
|
||||
console.error("IPPANEL send failed", error);
|
||||
return NextResponse.json({ error: "ارسال پیامک ناموفق بود" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
expiresIn: 120,
|
||||
});
|
||||
}
|
||||
95
app/api/auth/mobile/verify/route.ts
Normal file
95
app/api/auth/mobile/verify/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const IRAN_MOBILE_REGEX = /^(\+98|98|0)?9\d{9}$/;
|
||||
const SESSION_MAX_AGE_DAYS = 30;
|
||||
|
||||
function normalizeIranMobile(phone: string) {
|
||||
const digits = phone.replace(/[^\d+]/g, "");
|
||||
if (!IRAN_MOBILE_REGEX.test(digits)) return null;
|
||||
|
||||
if (digits.startsWith("+98")) return `0${digits.slice(3)}`;
|
||||
if (digits.startsWith("98")) return `0${digits.slice(2)}`;
|
||||
if (digits.startsWith("9")) return `0${digits}`;
|
||||
return digits;
|
||||
}
|
||||
|
||||
function buildPhoneEmail(phone: string) {
|
||||
return `${phone}@mobile.local`;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { phone, code, name } = await req.json().catch(() => ({}));
|
||||
const normalizedPhone = typeof phone === "string" ? normalizeIranMobile(phone) : null;
|
||||
const normalizedCode = typeof code === "string" ? code.trim() : "";
|
||||
|
||||
if (!normalizedPhone || !/^\d{6}$/.test(normalizedCode)) {
|
||||
return NextResponse.json({ error: "شماره موبایل یا کد ورود معتبر نیست" }, { status: 400 });
|
||||
}
|
||||
|
||||
const otp = await db.loginOtp.findFirst({
|
||||
where: {
|
||||
phone: normalizedPhone,
|
||||
consumedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!otp || otp.attempts >= 5) {
|
||||
return NextResponse.json({ error: "کد ورود نامعتبر یا منقضی شده است" }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValidCode = await bcrypt.compare(normalizedCode, otp.codeHash);
|
||||
if (!isValidCode) {
|
||||
await db.loginOtp.update({
|
||||
where: { id: otp.id },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
|
||||
return NextResponse.json({ error: "کد ورود نامعتبر است" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await db.user.upsert({
|
||||
where: { phone: normalizedPhone },
|
||||
update: {
|
||||
name: typeof name === "string" && name.trim() ? name.trim() : undefined,
|
||||
},
|
||||
create: {
|
||||
phone: normalizedPhone,
|
||||
email: buildPhoneEmail(normalizedPhone),
|
||||
name: typeof name === "string" && name.trim() ? name.trim() : null,
|
||||
password: await bcrypt.hash(crypto.randomBytes(24).toString("hex"), 10),
|
||||
},
|
||||
});
|
||||
|
||||
await db.loginOtp.update({
|
||||
where: { id: otp.id },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expires = new Date(Date.now() + SESSION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.session.create({
|
||||
data: {
|
||||
sessionToken: token,
|
||||
userId: user.id,
|
||||
expires,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
token,
|
||||
tokenType: "Bearer",
|
||||
expiresAt: expires.toISOString(),
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
81
app/api/fantasy-news/route.ts
Normal file
81
app/api/fantasy-news/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { requireApiAdmin } from "@/lib/apiAuth";
|
||||
|
||||
function summarize(description: string) {
|
||||
if (description.length <= 100) return description;
|
||||
return `${description.slice(0, 100)}...`;
|
||||
}
|
||||
|
||||
function parseNewsTime(value: unknown) {
|
||||
if (typeof value !== "string" && typeof value !== "number") return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const mode = req.headers.get("x-news-mode")?.toLowerCase();
|
||||
const summaryHeader = req.headers.get("x-news-summary")?.toLowerCase();
|
||||
const latestSummary = mode === "latest" || summaryHeader === "true";
|
||||
|
||||
const news = await db.fantasyNews.findMany({
|
||||
orderBy: [{ newsTime: "desc" }, { createdAt: "desc" }],
|
||||
take: latestSummary ? 4 : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: news.map((item) => ({
|
||||
id: item.id,
|
||||
icon: item.icon,
|
||||
title: item.title,
|
||||
description: latestSummary ? summarize(item.description) : item.description,
|
||||
newsTime: item.newsTime.toISOString(),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const admin = await requireApiAdmin(req);
|
||||
if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { icon, title, description, newsTime } = await req.json().catch(() => ({}));
|
||||
const parsedNewsTime = parseNewsTime(newsTime);
|
||||
|
||||
if (
|
||||
typeof icon !== "string" ||
|
||||
!icon.trim() ||
|
||||
typeof title !== "string" ||
|
||||
!title.trim() ||
|
||||
typeof description !== "string" ||
|
||||
!description.trim() ||
|
||||
!parsedNewsTime
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "icon، title، description و newsTime معتبر الزامی هستند" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const item = await db.fantasyNews.create({
|
||||
data: {
|
||||
icon: icon.trim(),
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
newsTime: parsedNewsTime,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: item.id,
|
||||
icon: item.icon,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
newsTime: item.newsTime.toISOString(),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
}
|
||||
32
lib/apiAuth.ts
Normal file
32
lib/apiAuth.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
export async function getApiUser(req: NextRequest) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const bearerToken = authHeader?.match(/^Bearer\s+(.+)$/i)?.[1];
|
||||
|
||||
if (bearerToken) {
|
||||
const session = await db.session.findUnique({
|
||||
where: { sessionToken: bearerToken },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (session && session.expires > new Date()) {
|
||||
return session.user;
|
||||
}
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
const userId = (session?.user as any)?.id;
|
||||
if (!userId) return null;
|
||||
|
||||
return db.user.findUnique({ where: { id: userId } });
|
||||
}
|
||||
|
||||
export async function requireApiAdmin(req: NextRequest) {
|
||||
const user = await getApiUser(req);
|
||||
if (!user || user.role !== "ADMIN") return null;
|
||||
return user;
|
||||
}
|
||||
42
lib/ippanel.ts
Normal file
42
lib/ippanel.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const IPPANEL_PATTERN_CODE = process.env.IPPANEL_PATTERN_CODE ?? "bhuf3xmo7k80uhr";
|
||||
const IPPANEL_TOKEN =
|
||||
process.env.IPPANEL_TOKEN ??
|
||||
"YTFiNmYzM2MtNTJkNi00ZTFjLTljZDItMDU4YjQwODNiOGU1YWZlNzIwZDBlYTM0Yzg4M2IyMzMzMmRiM2E4ZTVjNTM=";
|
||||
const IPPANEL_ORIGINATOR = process.env.IPPANEL_ORIGINATOR ?? "+983000505";
|
||||
const IPPANEL_BASE_URL = process.env.IPPANEL_BASE_URL ?? "https://edge.ippanel.com/v1";
|
||||
|
||||
type IppanelPatternResponse = {
|
||||
status?: string;
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export async function sendLoginCode(phone: string, code: string) {
|
||||
const recipient = phone.startsWith("0") ? `+98${phone.slice(1)}` : phone;
|
||||
|
||||
const response = await fetch(`${IPPANEL_BASE_URL}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: IPPANEL_TOKEN,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sending_type: "pattern",
|
||||
from_number: IPPANEL_ORIGINATOR,
|
||||
code: IPPANEL_PATTERN_CODE,
|
||||
recipients: [recipient],
|
||||
params: {
|
||||
code,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as IppanelPatternResponse | null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || "IPPANEL_SEND_FAILED");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
26
prisma/login-news-manual.sql
Normal file
26
prisma/login-news-manual.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "phone" TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "User_phone_key" ON "User"("phone");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "LoginOtp" (
|
||||
"id" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "LoginOtp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "LoginOtp_phone_createdAt_idx" ON "LoginOtp"("phone", "createdAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "FantasyNews" (
|
||||
"id" TEXT NOT NULL,
|
||||
"icon" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"newsTime" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "FantasyNews_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "FantasyNews_newsTime_idx" ON "FantasyNews"("newsTime");
|
||||
@@ -226,6 +226,7 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String @unique
|
||||
phone String? @unique
|
||||
password String
|
||||
role Role @default(USER)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -236,6 +237,30 @@ model User {
|
||||
goldenCards GoldenCard[]
|
||||
}
|
||||
|
||||
model LoginOtp {
|
||||
id String @id @default(cuid())
|
||||
phone String
|
||||
codeHash String
|
||||
attempts Int @default(0)
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([phone, createdAt])
|
||||
}
|
||||
|
||||
model FantasyNews {
|
||||
id String @id @default(cuid())
|
||||
icon String
|
||||
title String
|
||||
description String @db.Text
|
||||
newsTime DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([newsTime])
|
||||
}
|
||||
|
||||
enum GoldenCardStatus {
|
||||
SEALED
|
||||
OPENED
|
||||
|
||||
Reference in New Issue
Block a user