Compare commits

...

4 Commits

Author SHA1 Message Date
c40bd412f9 feat: new README.md 2025-06-13 15:26:38 +03:00
dcc6bf448b fix: dockerfile prisma generate 2025-06-11 13:07:00 +03:00
3953e550b1 feat: multi-subs working 2025-06-11 11:54:28 +03:00
2738a8e8bd feat: started multi-subs 2025-06-10 17:01:44 +03:00
13 changed files with 247 additions and 63 deletions

View File

@@ -15,6 +15,7 @@ COPY --from=install /temp/dev/node_modules node_modules
COPY . . COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
RUN bunx prisma generate
RUN bun run build RUN bun run build
FROM base AS release FROM base AS release

View File

@@ -1,36 +1,86 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Telegram MiniApp для удобного информирования о прокси-подписке
## Getting Started ## Для инициализации
First, run the development server: Сначала ставим пакеты:
```
```bash bun i
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Потом ставим prisma приколы:
```
bunx prisma generate
```
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. Потом копируем .env.example в .env:
```
cp .env.example .env
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. Заполняем его данными всякими (где про базу данных, нужно, чтобы URL соответствовал остальным параметрам), где XUI пишем авторизацию для 3X-UI.
## Learn More ---
To learn more about Next.js, take a look at the following resources: ## Для разработки
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. Для разработки поднимаем базу данных и редиску:
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. ```
docker-compose up -d db redis
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! Заносим в базу данных таблицы:
```
bunx prisma db push
```
## Deploy on Vercel Можно запускать приложение, оно будет на http://localhost:3000:
```
bun run dev
```
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ### Изменение БД
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Если нужно изменить schema.prisma, то нужно будет провести миграцию:
```
bunx prisma migrate dev --name *имя*
```
Для удобного взаимодействия с БД можно запустить prisma studio:
```
bunx prisma studio
```
### Для проверки в ТГ
В BotFather указываем у бота MiniApp url - `https://127.0.0.1:3000` и запускаем сервер:
```
bun run dev:https
```
---
## Для сборки и production запуска
```
docker compose up -d --build
```
### Если были изменения в БД:
```
docker compose exec app bunx prisma db push
```
---
## Подключение к БД сторонними клиентами
Подключиться к серверу:
```
ssh -L PORT1:localhost:PORT2 server
```
Где PORT1 - локальный порт, а PORT2 - порт БД на сервере. Подключаться нужно локально к PORT1.
То есть, можно указать в .env `DATABASE_URL=postgresql://user:password@localhost:PORT1/dbname` и открыть prisma studio:
```
bunx prisma studio
```

View File

@@ -8,6 +8,8 @@
"@telegram-apps/init-data-node": "^2.0.7", "@telegram-apps/init-data-node": "^2.0.7",
"@telegram-apps/sdk-react": "^3.3.0", "@telegram-apps/sdk-react": "^3.3.0",
"@telegram-apps/telegram-ui": "^2.1.8", "@telegram-apps/telegram-ui": "^2.1.8",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"lucide-react": "^0.513.0", "lucide-react": "^0.513.0",
"next": "15.3.3", "next": "15.3.3",
@@ -492,6 +494,12 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],

View File

@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
reactStrictMode: true,
}; };
export default nextConfig; export default nextConfig;

View File

@@ -14,6 +14,8 @@
"@telegram-apps/init-data-node": "^2.0.7", "@telegram-apps/init-data-node": "^2.0.7",
"@telegram-apps/sdk-react": "^3.3.0", "@telegram-apps/sdk-react": "^3.3.0",
"@telegram-apps/telegram-ui": "^2.1.8", "@telegram-apps/telegram-ui": "^2.1.8",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"lucide-react": "^0.513.0", "lucide-react": "^0.513.0",
"next": "15.3.3", "next": "15.3.3",

View File

@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "User_tgId_key";

View File

@@ -9,6 +9,6 @@ datasource db {
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
tgId String? @unique tgId String?
email String? @unique email String? @unique
} }

5
src/app/_dto/db.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface User {
id: string;
tgId: string | null;
email: string | null;
}

View File

@@ -4,6 +4,7 @@ import { authFetch } from "@/lib/login";
import { getValidUrl } from "@/lib/url"; import { getValidUrl } from "@/lib/url";
import { prisma } from "@/utils/prisma"; import { prisma } from "@/utils/prisma";
import { redis } from "@/utils/redis"; import { redis } from "@/utils/redis";
import { User } from "@prisma/client";
import { parse, validate } from "@telegram-apps/init-data-node"; import { parse, validate } from "@telegram-apps/init-data-node";
import { ClientSettings, InboundResponse } from "./_dto/inbounds"; import { ClientSettings, InboundResponse } from "./_dto/inbounds";
@@ -39,18 +40,19 @@ export async function getUrl(initData: string = "") {
throw new Error("User not found"); throw new Error("User not found");
} }
const cachedUser = await redis.get(`user:${initDataParsed.user.id}`); const cachedUser = await redis.get(`user:${initDataParsed.user.id}`);
const user = cachedUser const users: User[] = cachedUser
? JSON.parse(cachedUser) ? JSON.parse(cachedUser)
: await prisma.user.findFirst({ : await prisma.user.findMany({
where: { where: {
tgId: initDataParsed.user.id.toString(), tgId: initDataParsed.user.id.toString(),
}, },
}); });
if (!user) { if (!users.length) {
throw new Error("User not found"); throw new Error("User not found");
} }
await redis.set(`user:${initDataParsed.user.id}`, JSON.stringify(user), "EX", 60); await redis.set(`user:${initDataParsed.user.id}`, JSON.stringify(users), "EX", 60);
return await getUrlApi(user.email || ""); const usersUrl = await Promise.all(users.map(async (user) => await getUrlApi(user.email || "")));
return usersUrl;
} catch (e) { } catch (e) {
throw e; throw e;
} }

View File

@@ -1,35 +1,50 @@
"use client"; "use client";
import { Block } from "@/components/Block"; import { Block } from "@/components/Block";
import { Carousel } from "@/components/Carousel/Carousel";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Page } from "@/components/Page"; import { Page } from "@/components/Page";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { useRawInitData } from "@telegram-apps/sdk-react"; import { useRawInitData } from "@telegram-apps/sdk-react";
import { ClipboardList } from "lucide-react"; import { ChevronLeft, ChevronRight, ClipboardList } from "lucide-react";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getUrl } from "./actions"; import { getUrl } from "./actions";
import { EmblaCarouselType } from "embla-carousel";
interface ProxySubData {
url: string;
expiryTime: number;
}
type ProxySubArray = ProxySubData[];
export default function Home() { export default function Home() {
const [url, setUrl] = useState(""); const [proxyData, setProxyData] = useState<ProxySubArray>([]);
const [expiryTime, setExpiryTime] = useState(0); const [chosen, setChosen] = useState(0);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const carouselApi = useRef<EmblaCarouselType | null>(null);
const initData = useRawInitData(); const initData = useRawInitData();
const onCopyClick = async () => { const [isCopied, setIsCopied] = useState<boolean[]>([]);
if (!url.length) return;
useEffect(() => {
if (!proxyData.length) return;
setIsCopied(Array(proxyData.length).fill(false));
}, [proxyData]);
const onCopyClick = async (url: string) => {
if (!proxyData.length) return;
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setIsCopied(true); setIsCopied((prev) => prev.map((_, i) => i === chosen));
setTimeout(() => { setTimeout(() => {
setIsCopied(false); setIsCopied((prev) => prev.map(() => false));
}, 2000); }, 2000);
}; };
const [isCopied, setIsCopied] = useState(false);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const data = await getUrl(initData); const data = await getUrl(initData);
setUrl(data.url); setProxyData(data);
setExpiryTime(data.expiryTime);
setIsLoading(false); setIsLoading(false);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -57,40 +72,64 @@ export default function Home() {
<span className="text-xl font-semibold text-[var(--tg-theme-text-color)]">Nwaifu Proxy</span> <span className="text-xl font-semibold text-[var(--tg-theme-text-color)]">Nwaifu Proxy</span>
</header> </header>
<main className="absolute bottom-0 left-0 flex w-full flex-col items-center gap-3 px-2.5 py-3.5 text-[var(--tg-theme-text-color)]"> <main className="absolute bottom-0 left-0 flex w-full flex-col items-center gap-3 px-2.5 py-3.5 text-[var(--tg-theme-text-color)]">
{url && ( {proxyData.length && (
<> <>
<Block name="Ссылка"> <Block name={proxyData.length > 1 ? `Ссылка ${chosen + 1}/${proxyData.length}` : "Ссылка"}>
<div <div
className="relative flex size-52 flex-col items-center justify-center rounded-md bg-white" className={cn(
onClick={onCopyClick} "flex w-full flex-row items-center justify-between px-8",
proxyData.length === 1 && "justify-center"
)}
> >
<div {proxyData.length > 1 && <ChevronLeft onClick={() => carouselApi.current?.scrollPrev()} />}
className={cn( <div className="w-52">
"absolute top-0 left-0 flex h-full w-full flex-col items-center justify-center rounded-md bg-[var(--tg-theme-button-color)] opacity-0 transition-opacity", <Carousel
isCopied && "opacity-95" loop
)} setApi={(api) => (carouselApi.current = api)}
> onSelectIndex={(index) => setChosen(index)}
<span className="flex flex-row text-lg font-semibold text-[var(--tg-theme-button-text-color)]"> >
Скопировано {proxyData.map((item, index) => (
<ClipboardList className="text-[var(--tg-theme-button-text-color)]" /> <div
</span> className="relative flex size-52 max-w-52 flex-[0_0_100%] flex-col items-center justify-center rounded-md bg-white"
onClick={() => onCopyClick(item.url)}
key={index}
>
<div
className={cn(
"absolute top-0 left-0 flex h-full w-full flex-col items-center justify-center rounded-md bg-[var(--tg-theme-button-color)] opacity-0 transition-opacity",
isCopied[index] && "opacity-95"
)}
>
<span className="flex flex-row text-lg font-semibold text-[var(--tg-theme-button-text-color)]">
Скопировано
<ClipboardList className="text-[var(--tg-theme-button-text-color)]" />
</span>
</div>
<QRCodeSVG value={item.url} className="size-48" />
</div>
))}
</Carousel>
</div> </div>
<QRCodeSVG value={url} className="size-48" /> {proxyData.length > 1 && <ChevronRight onClick={() => carouselApi.current?.scrollNext()} />}
</div> </div>
<span className="text-center text-sm font-semibold">Нажмите на QR, чтобы скопировать!</span> <span className="text-center text-sm font-semibold">Нажмите на QR, чтобы скопировать!</span>
</Block> </Block>
<Block name="Подписка"> <Block name="Подписка">
<div className="flex flex-col items-center gap-0.5"> <div className="flex flex-col items-center gap-0.5">
<span>Статус:</span> <span>Статус:</span>
<span>{expiryTime > Date.now() || expiryTime === 0 ? "Активна" : "Не Активна"}</span> <span>
{proxyData[chosen].expiryTime > Date.now() || proxyData[chosen].expiryTime === 0
? "Активна"
: "Не Активна"}
</span>
</div> </div>
<div className="flex flex-col items-center gap-0.5"> <div className="flex flex-col items-center gap-0.5">
<span>Активна до:</span> <span>Активна до:</span>
<span> <span>
{expiryTime === 0 {proxyData[chosen].expiryTime === 0
? "Всегда" ? "Всегда"
: expiryTime > Date.now() : proxyData[chosen].expiryTime > Date.now()
? new Date(expiryTime).toLocaleString("ru-RU") ? new Date(proxyData[chosen].expiryTime).toLocaleString("ru-RU")
: "Не Активна"} : "Не Активна"}
</span> </span>
</div> </div>

View File

@@ -1,10 +1,16 @@
import { cn } from "@/utils/cn";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
type BlockProps = PropsWithChildren<{ name?: string }>; type BlockProps = PropsWithChildren<{ name?: string; className?: string }>;
const Block: React.FC<BlockProps> = ({ children, name = "" }) => { const Block: React.FC<BlockProps> = ({ children, name = "", className = "" }) => {
return ( return (
<div className="flex h-fit w-full flex-col items-center gap-2.5 rounded-lg bg-[var(--tg-theme-section-bg-color)] py-2.5 shadow-md"> <div
className={cn(
"flex h-fit w-full flex-col items-center gap-2.5 rounded-lg bg-[var(--tg-theme-section-bg-color)] py-2.5 shadow-md",
className
)}
>
{name && <span className="text-semibold text-lg">{name}</span>} {name && <span className="text-semibold text-lg">{name}</span>}
{children} {children}
</div> </div>

View File

@@ -0,0 +1,38 @@
"use client";
import { EmblaCarouselType, EmblaOptionsType } from "embla-carousel";
import useEmblaCarousel from "embla-carousel-react";
import { PropsWithChildren, useEffect, useState } from "react";
type Props = PropsWithChildren &
EmblaOptionsType & {
setApi?: (api: EmblaCarouselType) => void;
onSelectIndex?: (index: number) => void;
};
const Carousel: React.FC<Props> = ({ children, setApi, onSelectIndex, ...options }: Props) => {
const [emblaRef, emblaApi] = useEmblaCarousel(options);
const [, setSelectedIndex] = useState(0);
useEffect(() => {
if (!emblaApi) return;
if (emblaApi && setApi) setApi(emblaApi);
const selectHandler = () => {
const index = emblaApi.selectedScrollSnap();
setSelectedIndex(index);
onSelectIndex?.(index);
};
emblaApi.on("select", selectHandler);
return () => {
emblaApi.off("select", selectHandler);
};
}, [emblaApi, setApi, onSelectIndex]);
return (
<>
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">{children}</div>
</div>
</>
);
};
export { Carousel };

View File

@@ -0,0 +1,30 @@
"use client";
import { cn } from "@/utils/cn";
type Props = {
itemsLength: number;
selectedIndex: number;
};
const Dots: React.FC<Props> = ({ itemsLength, selectedIndex }) => {
const arr = new Array(itemsLength).fill(0);
return (
<div className="my-2 flex -translate-y-5 justify-center gap-1">
{arr.map((_, i) => {
const selected = i === selectedIndex;
return (
<span
className={cn(
"h-2 w-2 rounded-full bg-[--tg-theme-button-bg-color] transition-all duration-300",
!selected && "opacity-50"
)}
key={i}
/>
);
})}
</div>
);
};
Dots.displayName = "Dots";
export { Dots };