├── src ├── hooks │ └── index.ts ├── models │ ├── index.ts │ ├── resolvers │ │ ├── index.ts │ │ ├── socks5.ts │ │ ├── http.ts │ │ ├── juicity.ts │ │ ├── vmess.ts │ │ ├── tuic.ts │ │ ├── shadowsocksr.ts │ │ ├── vless.ts │ │ ├── trojan.ts │ │ └── shadowsocks.ts │ └── base.ts ├── apis │ ├── gql │ │ ├── index.ts │ │ └── fragment-masking.ts │ ├── query.ts │ └── mutation.ts ├── app │ ├── favicon.ico │ ├── assets │ │ └── twemoji.ttf │ ├── api │ │ ├── logout │ │ │ └── route.ts │ │ ├── avatar │ │ │ └── route.ts │ │ ├── update-password │ │ │ └── route.ts │ │ └── login │ │ │ └── route.ts │ ├── (protected) │ │ ├── rule │ │ │ ├── page.tsx │ │ │ ├── DNSSection.tsx │ │ │ └── RoutingSection.tsx │ │ ├── network │ │ │ ├── typings.ts │ │ │ ├── page.tsx │ │ │ ├── NodeSection.tsx │ │ │ ├── SubscriptionSection.tsx │ │ │ └── GroupSection.tsx │ │ ├── page.tsx │ │ ├── layout.tsx │ │ └── providers.tsx │ ├── error.tsx │ ├── providers.tsx │ ├── avatar │ │ └── [name] │ │ │ └── route.ts │ ├── bootstrap.tsx │ ├── layout.tsx │ └── (auth) │ │ └── login │ │ └── page.tsx ├── instrumentation.ts ├── components │ ├── Description.tsx │ ├── LoadingSpinner.tsx │ ├── Label.tsx │ ├── Button.tsx │ ├── LogoText.tsx │ ├── RandomUnsplashImage.tsx │ ├── ResourcePage.tsx │ ├── Editor.tsx │ ├── CodeBlock.tsx │ ├── NodeCard.tsx │ ├── ResourceRadioGroup.tsx │ ├── ListInput.tsx │ ├── Modal.tsx │ └── Header.tsx ├── types │ └── index.d.ts ├── constants │ └── index.ts ├── helpers │ ├── server │ │ └── data.ts │ └── index.tsx ├── schemas │ ├── group.ts │ ├── subscription.ts │ ├── routing.ts │ ├── dns.ts │ ├── account.ts │ ├── config.ts │ └── node.ts ├── bootstrap.ts ├── lib │ ├── helper.ts │ ├── time.ts │ └── time.test.ts ├── editor │ ├── options.ts │ ├── index.ts │ └── languages.ts ├── styles │ └── globals.css ├── i18n │ ├── index.ts │ └── locales │ │ ├── zh-Hans.json │ │ └── en-US.json └── contexts │ └── index.tsx ├── .dockerignore ├── .prettierignore ├── .eslintignore ├── .npmrc ├── .eslintrc ├── .commitlintrc ├── .gitmodules ├── .husky ├── commit-msg └── pre-commit ├── docs ├── preview-login.webp └── commit-msg-guide.md ├── postcss.config.js ├── public └── 809-1634-rocket-demo.riv ├── .env ├── docker-compose.yml ├── .lintstagedrc ├── .prettierrc ├── vitest.config.ts ├── .github ├── ISSUE_TEMPLATE │ ├── enhancement.md │ ├── bug-report.md │ ├── feature-request.md │ └── support-request.md ├── dependabot.yml └── workflows │ └── release.yml ├── codegen.ts ├── tailwind.config.ts ├── next.config.js ├── .gitignore ├── tsconfig.json ├── Dockerfile ├── LICENSE ├── package.json ├── CONTRIBUTING.md └── README.md /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | CHANGELOG.md -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .next 2 | src/apis/gql 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@nextui-org/* -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base' 2 | export * from './resolvers' 3 | -------------------------------------------------------------------------------- /src/apis/gql/index.ts: -------------------------------------------------------------------------------- 1 | export * from './fragment-masking' 2 | export * from './gql' 3 | -------------------------------------------------------------------------------- /.commitlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daeuniverse/daed-revived-next/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "wing"] 2 | path = wing 3 | url = https://github.com/daeuniverse/dae-wing.git 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm commitlint -e 5 | -------------------------------------------------------------------------------- /docs/preview-login.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daeuniverse/daed-revived-next/HEAD/docs/preview-login.webp -------------------------------------------------------------------------------- /src/app/assets/twemoji.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daeuniverse/daed-revived-next/HEAD/src/app/assets/twemoji.ttf -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm test:unit 5 | pnpm lint-staged 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/809-1634-rocket-demo.riv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daeuniverse/daed-revived-next/HEAD/public/809-1634-rocket-demo.riv -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_JWT_SECRET=daeuniverse 2 | NEXT_TELEMETRY_DISABLED=1 3 | 4 | HOSTNAME="0.0.0.0" 5 | 6 | WING_API_URL=http://localhost:2023 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | daed: 5 | build: . 6 | container_name: daed 7 | ports: 8 | - '3000:3000' 9 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "package.json": "sort-package-json", 3 | "*.{js,jsx,ts,tsx}": "eslint --fix", 4 | "*.{js,jsx,ts,tsx,md,html,css,json,yml,yaml}": "prettier --write" 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"] 7 | } 8 | -------------------------------------------------------------------------------- /src/instrumentation.ts: -------------------------------------------------------------------------------- 1 | export const register = async () => { 2 | if (process.env.NEXT_RUNTIME === 'nodejs') { 3 | const { bootstrap } = await import('~/bootstrap') 4 | 5 | await bootstrap() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Description.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | export const Description: FC<{ children: string }> = ({ children }) => { 4 | return

{children}

5 | } 6 | -------------------------------------------------------------------------------- /src/app/api/logout/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers' 2 | import { NextResponse } from 'next/server' 3 | 4 | export const POST = () => { 5 | cookies().delete('jwtToken') 6 | 7 | return new NextResponse() 8 | } 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { globals: true, environment: 'jsdom' } 7 | }) 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement Request 3 | about: Suggest an enhancement to the daed project 4 | labels: topic/enhancement 5 | --- 6 | 7 | ## What would you like us to improve 8 | 9 | ## Why is this needed 10 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | const LoadingSpinner: FC = () =>
4 | LoadingSpinner.displayName = 'LoadingSpinner' 5 | 6 | export { LoadingSpinner } 7 | -------------------------------------------------------------------------------- /src/components/Label.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react' 2 | 3 | export const Label: FC<{ children: ReactNode }> = ({ children }) => ( 4 | 5 | ) 6 | Label.displayName = 'Label' 7 | -------------------------------------------------------------------------------- /src/models/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./http" 2 | export * from "./juicity" 3 | export * from "./shadowsocks" 4 | export * from "./shadowsocksr" 5 | export * from "./socks5" 6 | export * from "./trojan" 7 | export * from "./tuic" 8 | export * from "./vless" 9 | export * from "./vmess" -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { LANG, resources } from '~/i18n' 2 | 3 | declare module 'i18next' { 4 | interface CustomTypeOptions { 5 | returnNull: false 6 | defaultNS: 'translation' 7 | resources: { translation: (typeof resources)[LANG.enUS]['translation'] } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const graphqlAPIURL = `http://${process.env.HOSTNAME}:${process.env.PORT}/api/wing/graphql` 2 | 3 | export enum NodeType { 4 | vmess, 5 | vless, 6 | shadowsocks, 7 | shadowsocksR, 8 | trojan, 9 | tuic, 10 | juicity, 11 | http, 12 | socks5 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/server/data.ts: -------------------------------------------------------------------------------- 1 | import envPaths from 'env-paths' 2 | import path from 'node:path' 3 | 4 | export const resolveAvatarDataPath = () => path.join(envPaths('daed').data, 'avatars') 5 | 6 | export const resolveAvatarPath = (avatarName: string) => path.join(resolveAvatarDataPath(), avatarName) 7 | -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import { CodegenConfig } from '@graphql-codegen/cli' 2 | 3 | export default { 4 | overwrite: true, 5 | schema: process.env.SCHEMA_PATH, 6 | documents: 'src/**/*', 7 | generates: { 'src/apis/gql/': { preset: 'client' } }, 8 | hooks: { afterOneFileWrite: ['prettier -w'] } 9 | } satisfies CodegenConfig 10 | -------------------------------------------------------------------------------- /src/schemas/group.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { Policy } from '~/apis/gql/graphql' 3 | 4 | export const groupFormSchema = z.object({ 5 | name: z.string().min(4).max(20), 6 | policy: z.nativeEnum(Policy) 7 | }) 8 | 9 | export const groupFormDefault: z.infer = { 10 | name: '', 11 | policy: Policy.MinMovingAvg 12 | } 13 | -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import envPaths from 'env-paths' 2 | import fs from 'node:fs/promises' 3 | import { resolveAvatarDataPath } from '~/helpers/server/data' 4 | 5 | export const bootstrap = async () => { 6 | const { data } = envPaths('daed') 7 | 8 | await fs.mkdir(data, { recursive: true }) 9 | await fs.mkdir(resolveAvatarDataPath(), { recursive: true }) 10 | } 11 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { nextui } from '@nextui-org/react' 2 | import { Config } from 'tailwindcss' 3 | 4 | export default { 5 | darkMode: 'class', 6 | content: ['./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}', './src/**/*.{css,ts,tsx}'], 7 | plugins: [ 8 | nextui({ 9 | addCommonColors: true 10 | }) 11 | ] 12 | } satisfies Config 13 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | output: process.env.STANDALONE && 'standalone', 4 | experimental: { instrumentationHook: true }, 5 | images: { remotePatterns: [{ hostname: 'source.unsplash.com' }] }, 6 | rewrites: () => [{ source: '/api/wing/:path*', destination: `${process.env.WING_API_URL}/:path*` }], 7 | reactStrictMode: false 8 | } 9 | -------------------------------------------------------------------------------- /src/app/(protected)/rule/page.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigSection } from './ConfigSection' 2 | import { DNSSection } from './DNSSection' 3 | import { RoutingSection } from './RoutingSection' 4 | 5 | export default function RulePage() { 6 | return ( 7 |
8 | 9 | 10 | 11 | 12 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonProps, Button as NextButton } from '@nextui-org/react' 2 | import { ElementRef, forwardRef } from 'react' 3 | 4 | export const Button = forwardRef, ButtonProps>(({ children, ...props }, ref) => ( 5 | 6 | {props.isIconOnly && props.isLoading ? null : children} 7 | 8 | )) 9 | Button.displayName = 'Button' 10 | -------------------------------------------------------------------------------- /src/schemas/subscription.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const subscriptionFormSchema = z.object({ 4 | subscriptions: z 5 | .array( 6 | z.object({ 7 | tag: z.string().min(1), 8 | link: z.string().min(1) 9 | }) 10 | ) 11 | .min(1) 12 | }) 13 | 14 | export const subscriptionFormDefault: z.infer = { 15 | subscriptions: [{ tag: '', link: '' }] 16 | } 17 | -------------------------------------------------------------------------------- /src/components/LogoText.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | 3 | const LogoText: FC = () => { 4 | return ( 5 |

6 | daed 7 |

8 | ) 9 | } 10 | LogoText.displayName = 'LogoText' 11 | 12 | export { LogoText } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: npm 5 | directory: / 6 | schedule: 7 | interval: daily 8 | 9 | - package-ecosystem: docker 10 | directory: / 11 | schedule: 12 | interval: daily 13 | 14 | - package-ecosystem: github-actions 15 | directory: / 16 | schedule: 17 | interval: daily 18 | 19 | - package-ecosystem: gitsubmodule 20 | directory: / 21 | schedule: 22 | interval: daily 23 | -------------------------------------------------------------------------------- /src/components/RandomUnsplashImage.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { FC } from 'react' 3 | import { randomUnsplashImageURL } from '~/helpers' 4 | 5 | const width = 512, 6 | height = 288 7 | 8 | export const RandomUnsplashImage: FC<{ sig: string }> = ({ sig }) => ( 9 | {sig} 16 | ) 17 | -------------------------------------------------------------------------------- /src/schemas/routing.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const routingFormSchema = z.object({ 4 | name: z.string().min(4).max(20), 5 | text: z.string().min(1) 6 | }) 7 | 8 | export const routingFormDefault: z.infer = { 9 | name: '', 10 | text: ` 11 | pname(NetworkManager, systemd-resolved, dnsmasq) -> must_direct 12 | dip(geoip:private) -> direct 13 | dip(geoip:cn) -> direct 14 | domain(geosite:cn) -> direct 15 | fallback: proxy 16 | `.trim() 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug encountered while operating daed 4 | labels: topic/bug 5 | --- 6 | 7 | ## What happened 8 | 9 | ## What you expect to happen 10 | 11 | ## How to reproduce it (as minimally and precisely as possible) 12 | 13 | ## Anything else we need to know 14 | 15 | ## Environment 16 | 17 | - daed version 18 | use `daed --version` 19 | 20 | - OS 21 | (e.g `cat /etc/os-release`) 22 | 23 | - Kernel 24 | (e.g. `uname -a`) 25 | -------------------------------------------------------------------------------- /src/components/ResourcePage.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react' 2 | 3 | export const ResourcePage: FC<{ name: string; creation?: ReactNode; children: ReactNode }> = ({ 4 | name, 5 | creation, 6 | children 7 | }) => { 8 | return ( 9 |
10 |
11 |

{name}

12 | 13 | {creation} 14 |
15 | 16 | {children} 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/schemas/dns.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const DNSFormSchema = z.object({ 4 | name: z.string().min(4).max(20), 5 | text: z.string().min(1) 6 | }) 7 | 8 | export const DNSFormDefault: z.infer = { 9 | name: '', 10 | text: ` 11 | upstream { 12 | alidns: 'udp://223.5.5.5:53' 13 | googledns: 'tcp+udp://8.8.8.8:53' 14 | } 15 | 16 | routing { 17 | request { 18 | qname(geosite:cn) -> alidns 19 | fallback: googledns 20 | } 21 | } 22 | `.trim() 23 | } 24 | -------------------------------------------------------------------------------- /src/app/(protected)/network/typings.ts: -------------------------------------------------------------------------------- 1 | export type Node = { 2 | id: string 3 | name: string 4 | tag?: string | null 5 | protocol: string 6 | link: string 7 | subscriptionID?: string | null 8 | } 9 | 10 | export type Subscription = { 11 | id: string 12 | tag?: string | null 13 | updatedAt: string 14 | 15 | nodes: { edges: Node[] } 16 | } 17 | 18 | export type Group = { 19 | id: string 20 | name: string 21 | policy: string 22 | nodes: Node[] 23 | subscriptions: Subscription[] 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: feature request related to daed 4 | labels: topic/feature 5 | --- 6 | 7 | 14 | 15 | ## What feature you would like us to integrate into the daed project 16 | 17 | ## Why is this needed 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support Request 3 | about: Support request related to Daed 4 | labels: topic/support 5 | --- 6 | 7 | 16 | 17 | ## What would you like us to support 18 | -------------------------------------------------------------------------------- /src/app/(protected)/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { Editor } from '~/components/Editor' 5 | 6 | export default function HomePage() { 7 | const [editorValue, setEditorValue] = useState( 8 | ` 9 | # Hello world 10 | 11 | this is a test message 12 | `.trim() 13 | ) 14 | 15 | return ( 16 |
17 |

daed

18 | 19 | setEditorValue(value || '')} /> 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Editor as BaseEditor, EditorProps } from '@monaco-editor/react' 4 | import { useTheme } from 'next-themes' 5 | import { FC } from 'react' 6 | import { options, themeDark, themeLight } from '~/editor/options' 7 | 8 | const Editor: FC = (props) => { 9 | const { resolvedTheme } = useTheme() 10 | 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | Editor.displayName = 'Editor' 18 | 19 | export { Editor } 20 | -------------------------------------------------------------------------------- /src/lib/helper.ts: -------------------------------------------------------------------------------- 1 | export class Defer { 2 | promise: Promise 3 | resolve?: (value: T | PromiseLike) => void 4 | reject?: (reason?: unknown) => void 5 | 6 | constructor() { 7 | this.promise = new Promise((resolve, reject) => { 8 | this.resolve = resolve 9 | this.reject = reject 10 | }) 11 | } 12 | } 13 | 14 | export const fileToBase64 = (file: File) => { 15 | const reader = new FileReader() 16 | reader.readAsDataURL(file) 17 | 18 | const defer = new Defer() 19 | reader.onload = () => defer.resolve?.(reader.result as string) 20 | reader.onerror = (err) => defer.reject?.(err) 21 | 22 | return defer.promise 23 | } 24 | -------------------------------------------------------------------------------- /src/app/api/avatar/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import fs from 'node:fs/promises' 3 | import { resolveAvatarPath } from '~/helpers/server/data' 4 | 5 | export const POST = async (req: Request) => { 6 | const formData = await req.formData() 7 | const avatar = formData.get('avatar') as File 8 | 9 | const avatarPath = resolveAvatarPath(avatar.name) 10 | 11 | try { 12 | await fs.writeFile(avatarPath, Buffer.from(await avatar.arrayBuffer())) 13 | 14 | return NextResponse.json({ url: `/avatar/${avatar.name}` }) 15 | } catch (err) { 16 | return NextResponse.json({ message: (err as Error).message }, { status: 503 }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { IconRefresh } from '@tabler/icons-react' 4 | import { useTranslation } from 'react-i18next' 5 | import { Button } from '~/components/Button' 6 | 7 | export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { 8 | const { t } = useTranslation() 9 | 10 | return ( 11 |
12 |
{error.message}
13 | 14 | 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/editor/options.ts: -------------------------------------------------------------------------------- 1 | import { EditorProps } from '@monaco-editor/react' 2 | 3 | export const themeDark = 'vs-dark' 4 | export const themeLight = 'githubLight' 5 | 6 | export const options: EditorProps['options'] = { 7 | cursorBlinking: 'solid', 8 | folding: false, 9 | fontFamily: 'var(--font-fira-code)', 10 | fontWeight: 'bold', 11 | formatOnPaste: true, 12 | glyphMargin: false, 13 | insertSpaces: true, 14 | lineHeight: 1.6, 15 | lineNumbers: 'off', 16 | minimap: { enabled: false }, 17 | padding: { top: 8, bottom: 8 }, 18 | renderWhitespace: 'selection', 19 | scrollBeyondLastLine: true, 20 | 'semanticHighlighting.enabled': true, 21 | tabSize: 2 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "types": ["vitest/globals"], 17 | "plugins": [{ "name": "next" }], 18 | "paths": { "~/*": ["./src/*"] } 19 | }, 20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # can't build on bun, because of this issue: 2 | # https://github.com/oven-sh/bun/issues/4671 3 | FROM docker.io/node AS base 4 | 5 | FROM base AS builder 6 | 7 | WORKDIR /build 8 | 9 | COPY . . 10 | 11 | ENV HUSKY 0 12 | ENV STANDALONE 1 13 | 14 | RUN corepack enable 15 | RUN corepack prepare pnpm@latest --activate 16 | 17 | RUN pnpm install 18 | RUN pnpm build 19 | 20 | FROM base AS runner 21 | 22 | WORKDIR /app 23 | 24 | COPY --from=builder /build/public ./public 25 | 26 | RUN mkdir .next 27 | 28 | COPY --from=builder /build/.next/standalone . 29 | COPY --from=builder /build/.next/static ./.next/static 30 | 31 | ENV NODE_ENV production 32 | 33 | EXPOSE 3000 34 | 35 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { NextUIProvider } from '@nextui-org/react' 4 | import { ThemeProvider, useTheme } from 'next-themes' 5 | import { FC, ReactNode } from 'react' 6 | import { Toaster } from 'sonner' 7 | 8 | const ToasterProvider = () => { 9 | const { theme } = useTheme() 10 | 11 | return 12 | } 13 | 14 | export const Providers: FC<{ children: ReactNode }> = ({ children }) => ( 15 | 16 | 17 | {children} 18 | 19 | 20 | 21 | 22 | ) 23 | -------------------------------------------------------------------------------- /src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useTheme } from 'next-themes' 4 | import { FC } from 'react' 5 | import SyntaxHighlighter, { SyntaxHighlighterProps } from 'react-syntax-highlighter' 6 | import { atomOneDark, atomOneLight } from 'react-syntax-highlighter/dist/esm/styles/hljs' 7 | 8 | const CodeBlock: FC<{ children: string } & SyntaxHighlighterProps> = ({ children, ...props }) => { 9 | const { resolvedTheme } = useTheme() 10 | 11 | return ( 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | CodeBlock.displayName = 'CodeBlock' 18 | 19 | export { CodeBlock } 20 | -------------------------------------------------------------------------------- /src/components/NodeCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, CardFooter, CardHeader, Tooltip } from '@nextui-org/react' 2 | import { FC } from 'react' 3 | import { Node } from '~/apis/gql/graphql' 4 | import { RandomUnsplashImage } from '~/components/RandomUnsplashImage' 5 | 6 | export const NodeCard: FC<{ node: Node }> = ({ node }) => ( 7 | 8 | 9 | {node.tag || node.name} 10 | 11 | 12 | 13 | 14 | 15 | {node.protocol} 16 | 17 | 18 | ) 19 | -------------------------------------------------------------------------------- /src/app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | import { ReactNode } from 'react' 3 | import { Header } from '~/components/Header' 4 | import { decodeJWTFromCookie } from '~/helpers' 5 | import { Providers } from './providers' 6 | 7 | export default function ProtectedLayout({ children }: { children: ReactNode }) { 8 | const jwtPayload = decodeJWTFromCookie() 9 | 10 | if (!jwtPayload) redirect('/login') 11 | 12 | const { token } = jwtPayload 13 | 14 | return ( 15 | 16 |
17 | 18 |
19 |
{children}
20 |
21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/(protected)/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 5 | import { FC, ReactNode } from 'react' 6 | import { GraphqlClientProvider, SessionContextProps, SessionProvider } from '~/contexts' 7 | 8 | export const Providers: FC = ({ token, children }) => ( 9 | 10 | 11 | 12 | {children} 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | -------------------------------------------------------------------------------- /src/app/avatar/[name]/route.ts: -------------------------------------------------------------------------------- 1 | import mime from 'mime' 2 | import { NextResponse } from 'next/server' 3 | import fs from 'node:fs/promises' 4 | import { resolveAvatarPath } from '~/helpers/server/data' 5 | 6 | export const GET = async (_request: Request, { params }: { params: { name: string } }) => { 7 | const { name } = params 8 | const avatarPath = resolveAvatarPath(name) 9 | 10 | try { 11 | await fs.access(avatarPath, fs.constants.F_OK) 12 | 13 | const fileContent = await fs.readFile(avatarPath) 14 | const fileType = mime.getType(avatarPath) || '' 15 | 16 | return new NextResponse(fileContent, { headers: { 'Content-Type': fileType } }) 17 | } catch { 18 | return new NextResponse(null, { status: 404 }) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | scrollbar-width: thin; 7 | scrollbar-color: transparent transparent; 8 | font-family: var(--font-geist-sans), var(--font-twemoji); 9 | } 10 | 11 | ::-webkit-scrollbar { 12 | @apply hidden bg-transparent; 13 | } 14 | 15 | form { 16 | @apply contents; 17 | } 18 | 19 | input { 20 | @apply bg-clip-text; 21 | } 22 | 23 | @media screen(sm) { 24 | ::-webkit-scrollbar { 25 | @apply block; 26 | } 27 | 28 | ::-webkit-scrollbar:vertical { 29 | @apply w-1.5; 30 | } 31 | 32 | ::-webkit-scrollbar:horizontal { 33 | @apply h-1.5; 34 | } 35 | 36 | ::-webkit-scrollbar-thumb { 37 | @apply rounded bg-primary; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/helpers/index.tsx: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { cookies } from 'next/headers' 3 | 4 | export const randomUnsplashImageURL = (sig: string, width: number, height: number) => 5 | `https://source.unsplash.com/random/${width}x${height}?goose&sig=${sig}` 6 | 7 | export const decodeJWTFromCookie = () => { 8 | const jwtToken = cookies().get('jwtToken') 9 | 10 | if (!jwtToken) return null 11 | 12 | return jwt.decode(jwtToken.value, { json: true })! as { token: string } 13 | } 14 | 15 | export const storeJWTAsCookie = (token: string) => { 16 | const jwtToken = jwt.sign({ token }, process.env.NEXT_PUBLIC_JWT_SECRET!) 17 | 18 | cookies().set('jwtToken', jwtToken, { 19 | maxAge: 60 * 60 * 24 * 30, // 30 days 20 | httpOnly: true, 21 | path: '/' 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/editor/index.ts: -------------------------------------------------------------------------------- 1 | import { loader } from '@monaco-editor/react' 2 | import type { editor } from 'monaco-editor' 3 | import { daeLang } from '~/editor/languages' 4 | 5 | export const initializeEditor = async () => { 6 | self.MonacoEnvironment = { 7 | getWorker: () => new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker', import.meta.url)) 8 | } 9 | 10 | const monaco = await import('monaco-editor') 11 | 12 | loader.config({ monaco }) 13 | 14 | const monacoInstance = await loader.init() 15 | 16 | monacoInstance.languages.register({ id: 'dae', extensions: ['dae'] }) 17 | monacoInstance.languages.setMonarchTokensProvider('dae', daeLang) 18 | 19 | const themeGithubLight = await import('monaco-themes/themes/GitHub Light.json') 20 | 21 | monacoInstance.editor.defineTheme('githubLight', themeGithubLight as editor.IStandaloneThemeData) 22 | } 23 | -------------------------------------------------------------------------------- /src/app/bootstrap.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import dayjs from 'dayjs' 4 | import duration from 'dayjs/plugin/duration' 5 | import { FC, ReactNode } from 'react' 6 | import { useAsync } from 'react-use' 7 | import { LoadingSpinner } from '~/components/LoadingSpinner' 8 | import { initializeEditor } from '~/editor' 9 | import { initializeI18n } from '~/i18n' 10 | 11 | export const Bootstrap: FC<{ children: ReactNode }> = ({ children }) => { 12 | const { loading } = useAsync(async () => { 13 | dayjs.extend(duration) 14 | 15 | try { 16 | await initializeI18n() 17 | await initializeEditor() 18 | } catch {} 19 | }, []) 20 | 21 | if (loading) { 22 | return ( 23 |
24 | 25 | 26 | Loading assets... 27 |
28 | ) 29 | } 30 | 31 | return children 32 | } 33 | -------------------------------------------------------------------------------- /src/components/ResourceRadioGroup.tsx: -------------------------------------------------------------------------------- 1 | import { Radio, RadioGroup, RadioGroupProps, RadioProps } from '@nextui-org/react' 2 | import { ElementRef, forwardRef } from 'react' 3 | 4 | export const ResourceRadio = forwardRef, RadioProps>((props, ref) => { 5 | return ( 6 | 15 | ) 16 | }) 17 | ResourceRadio.displayName = 'ResourceRadio' 18 | 19 | export const ResourceRadioGroup = forwardRef, RadioGroupProps>((props, ref) => ( 20 | 21 | )) 22 | ResourceRadioGroup.displayName = 'ResourceRadioGroup' 23 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import i18n from 'i18next' 4 | import detectLanguage from 'i18next-browser-languagedetector' 5 | import { initReactI18next } from 'react-i18next' 6 | import { z } from 'zod' 7 | import { zodI18nMap } from 'zod-i18n-map' 8 | import zodEn from 'zod-i18n-map/locales/en/zod.json' 9 | import zodZhHans from 'zod-i18n-map/locales/zh-CN/zod.json' 10 | import enUS from '~/i18n/locales/en-US.json' 11 | import zhHans from '~/i18n/locales/zh-Hans.json' 12 | 13 | export enum LANG { 14 | enUS = 'en-US', 15 | zhHans = 'zh-Hans' 16 | } 17 | 18 | export const resources = { 19 | [LANG.enUS]: { translation: enUS, zod: zodEn }, 20 | [LANG.zhHans]: { translation: zhHans, zod: zodZhHans } 21 | } as const 22 | 23 | export const initializeI18n = async () => { 24 | await i18n.use(initReactI18next).use(detectLanguage).init({ 25 | resources, 26 | fallbackLng: LANG.enUS, 27 | returnNull: false 28 | }) 29 | 30 | z.setErrorMap(zodI18nMap) 31 | } 32 | -------------------------------------------------------------------------------- /src/app/api/update-password/route.ts: -------------------------------------------------------------------------------- 1 | import { request } from 'graphql-request' 2 | import { NextResponse } from 'next/server' 3 | import { UpdatePasswordMutation } from '~/apis/gql/graphql' 4 | import { updatePasswordMutation } from '~/apis/mutation' 5 | import { graphqlAPIURL } from '~/constants' 6 | import { decodeJWTFromCookie, storeJWTAsCookie } from '~/helpers' 7 | 8 | export const POST = async (req: Request) => { 9 | const jwtPayload = decodeJWTFromCookie() 10 | 11 | if (!jwtPayload) { 12 | return new NextResponse(null, { status: 401 }) 13 | } 14 | 15 | const { currentPassword, newPassword } = await req.json() 16 | 17 | const { token } = jwtPayload 18 | 19 | const { updatePassword } = await request( 20 | graphqlAPIURL, 21 | updatePasswordMutation, 22 | { currentPassword, newPassword }, 23 | { Authorization: `Bearer ${token}` } 24 | ) 25 | 26 | storeJWTAsCookie(updatePassword) 27 | 28 | return new NextResponse() 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/time.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | const timeReg = /([0-9]+)([a-zA-Z]+)/ 4 | 5 | type Time = { 6 | hours: number 7 | minutes: number 8 | seconds: number 9 | milliseconds: number 10 | } 11 | 12 | export const parseDigitAndUnit = ( 13 | timeStr: string, 14 | output: Time = { hours: 0, milliseconds: 0, minutes: 0, seconds: 0 } 15 | ): Time => { 16 | const matchRes = timeStr.match(timeReg) 17 | 18 | if (!matchRes) return output 19 | 20 | const digit = Number.parseInt(matchRes[1]) 21 | const unit = matchRes[2].toLowerCase() 22 | 23 | switch (unit) { 24 | case 'h': 25 | output.hours = digit 26 | 27 | break 28 | case 'm': 29 | output.minutes = digit 30 | 31 | break 32 | case 's': 33 | output.seconds = digit 34 | 35 | break 36 | case 'ms': 37 | output.milliseconds = digit 38 | 39 | break 40 | } 41 | 42 | return parseDigitAndUnit(timeStr.replace(timeReg, ''), output) 43 | } 44 | 45 | export const deriveTime = (timeStr: string, outputUnit: 'ms' | 's') => 46 | dayjs.duration(parseDigitAndUnit(timeStr)).as(outputUnit === 'ms' ? 'milliseconds' : 'seconds') 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2023 daeuniverse 2 | 3 | Permission is hereby granted, free of 4 | charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/schemas/account.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import { z } from 'zod' 4 | 5 | export const loginSchema = z.object({ 6 | username: z.string().min(4).max(20), 7 | password: z.string().min(6).max(20) 8 | }) 9 | 10 | export const loginFormDefault: z.infer = { 11 | username: '', 12 | password: '' 13 | } 14 | 15 | export const updatePasswordSchema = z.object({ 16 | currentPassword: z.string().min(6).max(20), 17 | newPassword: z.string().min(6).max(20), 18 | confirmPassword: z.string().min(6).max(20) 19 | }) 20 | 21 | export const useUpdatePasswordSchemaWithRefine = () => { 22 | const { t } = useTranslation() 23 | 24 | return useCallback( 25 | () => 26 | updatePasswordSchema.refine(({ newPassword, confirmPassword }) => newPassword === confirmPassword, { 27 | message: t('form.errors.passwordDontMatch'), 28 | path: ['confirmPassword'] 29 | }), 30 | 31 | [t] 32 | ) 33 | } 34 | 35 | export const updatePasswordFormDefault: z.infer = { 36 | currentPassword: '', 37 | newPassword: '', 38 | confirmPassword: '' 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/time.test.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import duration from 'dayjs/plugin/duration' 3 | 4 | import { deriveTime } from './time' 5 | 6 | beforeAll(() => { 7 | dayjs.extend(duration) 8 | }) 9 | 10 | test('deriveTime can parse hours', () => { 11 | expect(deriveTime('1h', 's')).toBe(3600) 12 | expect(deriveTime('1H', 's')).toBe(3600) 13 | }) 14 | 15 | test('deriveTime can parse minutes', () => { 16 | expect(deriveTime('1m', 's')).toBe(60) 17 | expect(deriveTime('1M', 's')).toBe(60) 18 | }) 19 | 20 | test('deriveTime can parse seconds', () => { 21 | expect(deriveTime('60s', 's')).toBe(60) 22 | expect(deriveTime('60S', 's')).toBe(60) 23 | }) 24 | 25 | test('deriveTime can parse multiple units combined', () => { 26 | expect(deriveTime('1h1m1s100ms', 's')).toBe(3661.1) 27 | expect(deriveTime('1H1m1S100mS', 's')).toBe(3661.1) 28 | }) 29 | 30 | test('deriveTime can parse seconds to milliseconds', () => { 31 | expect(deriveTime('1s', 'ms')).toBe(1000) 32 | expect(deriveTime('1S', 'ms')).toBe(1000) 33 | }) 34 | 35 | test('deriveTime can parse milliseconds to seconds', () => { 36 | expect(deriveTime('1000ms', 's')).toBe(1) 37 | expect(deriveTime('1000Ms', 's')).toBe(1) 38 | }) 39 | -------------------------------------------------------------------------------- /src/components/ListInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Input } from '@nextui-org/react' 4 | import { IconPlus, IconTrash } from '@tabler/icons-react' 5 | import { FC } from 'react' 6 | import { Controller, useFieldArray } from 'react-hook-form' 7 | import { Button } from '~/components/Button' 8 | 9 | export const ListInput: FC<{ name: string }> = ({ name }) => { 10 | const { fields, append, remove } = useFieldArray({ name }) 11 | 12 | return ( 13 |
14 | {fields.map((item, index) => ( 15 | ( 19 |
20 | 21 | 22 | 25 |
26 | )} 27 | /> 28 | ))} 29 | 30 |
31 | 34 |
35 |
36 | ) 37 | } 38 | ListInput.displayName = 'ListInput' 39 | -------------------------------------------------------------------------------- /src/models/resolvers/socks5.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { NodeType } from '~/constants' 3 | import { BaseNodeResolver } from '~/models' 4 | import { GenerateURLParams } from '~/models/base' 5 | import { socks5Default, socks5Schema } from '~/schemas/node' 6 | 7 | export class Socks5NodeResolver extends BaseNodeResolver { 8 | type = NodeType.socks5 9 | schema = socks5Schema 10 | defaultValues = socks5Default 11 | 12 | generate = (values: z.infer) => { 13 | const generateURLParams: GenerateURLParams = { 14 | protocol: 'socks5', 15 | host: values.host, 16 | port: values.port, 17 | hash: values.name 18 | } 19 | 20 | if (values.username && values.password) { 21 | Object.assign(generateURLParams, { 22 | username: values.username, 23 | password: values.password 24 | }) 25 | } 26 | 27 | return this.generateURL(generateURLParams) 28 | } 29 | 30 | resolve(url: string) { 31 | const u = this.parseURL(url) 32 | 33 | return { 34 | username: decodeURIComponent(u.username), 35 | password: decodeURIComponent(u.password), 36 | host: u.host, 37 | port: u.port, 38 | protocol: u.protocol, 39 | name: decodeURIComponent(u.hash) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/(protected)/network/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useTranslation } from 'react-i18next' 4 | import { useNodesQuery, useSubscriptionsQuery } from '~/apis/query' 5 | import { GroupSection } from '~/app/(protected)/network/GroupSection' 6 | import { NodeSection } from '~/app/(protected)/network/NodeSection' 7 | import { SubscriptionSection } from '~/app/(protected)/network/SubscriptionSection' 8 | import { ResourcePage } from '~/components/ResourcePage' 9 | 10 | export default function NetworkPage() { 11 | const { t } = useTranslation() 12 | 13 | const subscriptionsQuery = useSubscriptionsQuery() 14 | const nodesQuery = useNodesQuery() 15 | 16 | return ( 17 | 18 |
19 | 23 | 24 |
25 | 26 | 27 | 28 |
29 |
30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/models/resolvers/http.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { NodeType } from '~/constants' 3 | import { BaseNodeResolver } from '~/models' 4 | import { GenerateURLParams } from '~/models/base' 5 | import { httpDefault, httpSchema } from '~/schemas/node' 6 | 7 | export class HTTPNodeResolver extends BaseNodeResolver { 8 | type = NodeType.http 9 | schema = httpSchema 10 | defaultValues = httpDefault 11 | 12 | generate = (values: z.infer & { protocol: 'http' | 'https' }) => { 13 | const generateURLParams: GenerateURLParams = { 14 | protocol: values.protocol, 15 | host: values.host, 16 | port: values.port, 17 | hash: values.name 18 | } 19 | 20 | if (values.username && values.password) { 21 | Object.assign(generateURLParams, { 22 | username: values.username, 23 | password: values.password 24 | }) 25 | } 26 | 27 | return this.generateURL(generateURLParams) 28 | } 29 | 30 | resolve(url: string) { 31 | const u = this.parseURL(url) 32 | 33 | return { 34 | username: decodeURIComponent(u.username), 35 | password: decodeURIComponent(u.password), 36 | host: u.host, 37 | port: u.port, 38 | protocol: u.protocol, 39 | name: decodeURIComponent(u.hash) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/models/resolvers/juicity.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { NodeType } from '~/constants' 3 | import { BaseNodeResolver } from '~/models' 4 | import { juicityDefault, juicitySchema } from '~/schemas/node' 5 | 6 | export class JuicityNodeResolver extends BaseNodeResolver { 7 | type = NodeType.juicity 8 | schema = juicitySchema 9 | defaultValues = juicityDefault 10 | 11 | generate = (values: z.infer) => 12 | this.generateURL({ 13 | protocol: 'juicity', 14 | username: values.uuid, 15 | password: values.password, 16 | host: values.server, 17 | port: values.port, 18 | hash: values.name, 19 | params: { 20 | congestion_control: values.congestion_control, 21 | sni: values.sni, 22 | allow_insecure: values.allowInsecure 23 | } 24 | }) 25 | 26 | resolve(url: string) { 27 | const u = this.parseURL(url) 28 | 29 | return { 30 | name: decodeURIComponent(u.hash), 31 | uuid: decodeURIComponent(u.username), 32 | password: decodeURIComponent(u.password), 33 | server: u.host, 34 | port: u.port, 35 | sni: u.params.sni || '', 36 | allowInsecure: u.params.allow_insecure === true || u.params.allow_insecure === '1', 37 | pinnedCertchainSha256: u.params.pinned_certchain_sha256 || '', 38 | congestion_control: u.params.congestion_control || 'bbr' 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/models/resolvers/vmess.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from 'js-base64' 2 | import { z } from 'zod' 3 | import { NodeType } from '~/constants' 4 | import { BaseNodeResolver } from '~/models' 5 | import { v2rayDefault, v2raySchema } from '~/schemas/node' 6 | 7 | export class VmessNodeResolver extends BaseNodeResolver { 8 | type = NodeType.vmess 9 | schema = v2raySchema 10 | defaultValues = v2rayDefault 11 | 12 | generate(values: z.infer) { 13 | const { net } = values 14 | const body: Record = structuredClone(values) 15 | 16 | switch (net) { 17 | case 'kcp': 18 | case 'tcp': 19 | default: 20 | body.type = '' 21 | } 22 | 23 | switch (body.net) { 24 | case 'ws': 25 | case 'h2': 26 | case 'grpc': 27 | case 'kcp': 28 | default: 29 | if (body.net === 'tcp' && body.type === 'http') { 30 | break 31 | } 32 | 33 | body.path = '' 34 | } 35 | 36 | if (!(body.protocol === 'vless' && body.tls === 'xtls')) { 37 | delete body.flow 38 | } 39 | 40 | return 'vmess://' + Base64.encode(JSON.stringify(body)) 41 | } 42 | 43 | resolve(url: string) { 44 | const values = JSON.parse(Base64.decode(url.substring(url.indexOf('://') + 3))) 45 | 46 | values.ps = decodeURIComponent(values.ps) 47 | values.tls = values.tls || 'none' 48 | values.type = values.type || 'none' 49 | values.scy = values.scy || 'auto' 50 | 51 | return values 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/models/resolvers/tuic.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { NodeType } from '~/constants' 3 | import { BaseNodeResolver } from '~/models' 4 | import { tuicDefault, tuicSchema } from '~/schemas/node' 5 | 6 | export class TuicNodeResolver extends BaseNodeResolver { 7 | type = NodeType.tuic 8 | schema = tuicSchema 9 | defaultValues = tuicDefault 10 | 11 | generate = (values: z.infer) => 12 | this.generateURL({ 13 | protocol: 'tuic', 14 | username: values.uuid, 15 | password: values.password, 16 | host: values.server, 17 | port: values.port, 18 | hash: values.name, 19 | params: { 20 | congestion_control: values.congestion_control, 21 | alpn: values.alpn, 22 | sni: values.sni, 23 | allow_insecure: values.allowInsecure, 24 | disable_sni: values.disable_sni, 25 | udp_relay_mode: values.udp_relay_mode 26 | } 27 | }) 28 | 29 | resolve(url: string) { 30 | const u = this.parseURL(url) 31 | 32 | return { 33 | name: decodeURIComponent(u.hash), 34 | uuid: decodeURIComponent(u.username), 35 | password: decodeURIComponent(u.password), 36 | server: u.host, 37 | port: u.port, 38 | sni: u.params.sni || '', 39 | allowInsecure: u.params.allow_insecure === true || u.params.allow_insecure === '1', 40 | disable_sni: u.params.disable_sni === true || u.params.disable_sni === '1', 41 | alpn: u.params.alpn, 42 | congestion_control: u.params.congestion_control || 'bbr', 43 | udp_relay_mode: u.params.udp_relay_mode || 'native' 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | release_created: ${{ steps.release.outputs.release_created }} 13 | tag_name: ${{ steps.release.outputs.tag_name }} 14 | 15 | steps: 16 | - name: release-please 17 | id: release 18 | uses: google-github-actions/release-please-action@v3 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | release-type: node 22 | 23 | release-image: 24 | needs: release-please 25 | runs-on: ubuntu-latest 26 | if: ${{ needs.release-please.outputs.release_created }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: set up QEMU 31 | uses: docker/setup-qemu-action@v3 32 | 33 | - name: set up docker buildx 34 | uses: docker/setup-buildx-action@v3 35 | id: buildx 36 | 37 | - name: login to ghcr.io 38 | uses: docker/login-action@v3 39 | with: 40 | registry: ghcr.io 41 | username: ${{ github.actor }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - name: build and publish ghcr.io docker image 45 | uses: docker/build-push-action@v5 46 | with: 47 | context: . 48 | builder: ${{ steps.buildx.outputs.name }} 49 | file: Dockerfile 50 | platforms: linux/amd64,linux/arm64 51 | push: true 52 | tags: | 53 | ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.tag_name }} 54 | cache-from: type=gha 55 | cache-to: type=gha,mode=max 56 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@nextui-org/react' 2 | import { GeistSans } from 'geist/font' 3 | import { Metadata, Viewport } from 'next' 4 | import { Fira_Code } from 'next/font/google' 5 | import localFont from 'next/font/local' 6 | import { ReactNode } from 'react' 7 | import { Bootstrap } from './bootstrap' 8 | import { Providers } from './providers' 9 | 10 | import '~/styles/globals.css' 11 | 12 | // for code editor 13 | const firaCode = Fira_Code({ 14 | subsets: ['latin', 'latin-ext', 'cyrillic', 'cyrillic-ext'], 15 | weight: ['400', '700'], 16 | variable: '--font-fira-code' 17 | }) 18 | 19 | // for emojis and other characters, such as national flags 20 | const twemojiFont = localFont({ 21 | src: './assets/twemoji.ttf', 22 | variable: '--font-twemoji', 23 | display: 'swap' 24 | }) 25 | 26 | export const metadata: Metadata = { 27 | title: 'daed', 28 | description: 'A modern dashboard for dae' 29 | } 30 | 31 | export const viewport: Viewport = { 32 | colorScheme: 'dark light', 33 | width: 'device-width', 34 | initialScale: 1, 35 | minimumScale: 1, 36 | maximumScale: 1, 37 | userScalable: false, 38 | viewportFit: 'cover' 39 | } 40 | 41 | export default async function RootLayout({ children }: { children: ReactNode }) { 42 | return ( 43 | 48 | 49 | 50 | 51 |
52 | {children} 53 |
54 |
55 |
56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/editor/languages.ts: -------------------------------------------------------------------------------- 1 | import { languages } from 'monaco-editor' 2 | 3 | export const daeLang: languages.IMonarchLanguage = { 4 | // set defaultToken as `invalid` to turn on debug mode 5 | // defaultToken: 'invalid', 6 | ignoreCase: false, 7 | keywords: [ 8 | 'dip', 9 | 'direct', 10 | 'domain', 11 | 'dport', 12 | 'fallback', 13 | 'ipversion', 14 | 'l4proto', 15 | 'mac', 16 | 'must_direct', 17 | 'pname', 18 | 'qname', 19 | 'request', 20 | 'response', 21 | 'routing', 22 | 'sip', 23 | 'sport', 24 | 'tcp', 25 | 'udp', 26 | 'upstream' 27 | ], 28 | 29 | escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/, 30 | 31 | symbols: /[->&!:,]+/, 32 | 33 | operators: ['&&', '!'], 34 | 35 | tokenizer: { 36 | root: [ 37 | [/@[a-zA-Z]\w*/, 'tag'], 38 | 39 | [/[a-zA-Z]\w*/, { cases: { '@keywords': 'keyword', '@default': 'identifier' } }], 40 | 41 | { include: '@whitespace' }, 42 | 43 | [/[{}()]/, '@brackets'], 44 | 45 | [/@symbols/, { cases: { '@operators': 'operator', '@default': '' } }], 46 | 47 | [/\d+/, 'number'], 48 | 49 | [/[,:]/, 'delimiter'], 50 | 51 | [/"([^"\\]|\\.)*$/, 'string.invalid'], 52 | [/'([^'\\]|\\.)*$/, 'string.invalid'], 53 | [/"/, 'string', '@string_double'], 54 | [/'/, 'string', '@string_single'] 55 | ], 56 | 57 | string_double: [ 58 | [/[^\\"]+/, 'string'], 59 | [/@escapes/, 'string.escape'], 60 | [/\\./, 'string.escape.invalid'], 61 | [/"/, 'string', '@pop'] 62 | ], 63 | 64 | string_single: [ 65 | [/[^\\']+/, 'string'], 66 | [/@escapes/, 'string.escape'], 67 | [/\\./, 'string.escape.invalid'], 68 | [/'/, 'string', '@pop'] 69 | ], 70 | 71 | whitespace: [ 72 | [/[ \t\r\n]+/, 'white'], 73 | [/#.*$/, 'comment'] 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/contexts/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ClientError, GraphQLClient } from 'graphql-request' 4 | import { useRouter } from 'next/navigation' 5 | import { FC, ReactNode, createContext, useContext, useMemo } from 'react' 6 | import { toast } from 'sonner' 7 | 8 | export type SessionContextProps = { token: string } 9 | 10 | const SessionContext = createContext(null as unknown as { token: string }) 11 | 12 | export const useSession = () => useContext(SessionContext) 13 | 14 | export const SessionProvider: FC = ({ token, children }) => { 15 | const router = useRouter() 16 | 17 | if (!token) { 18 | router.replace('/login') 19 | 20 | return null 21 | } 22 | 23 | return {children} 24 | } 25 | 26 | const GraphqlClientContext = createContext(null as unknown as GraphQLClient) 27 | 28 | export const useGraphqlClient = () => useContext(GraphqlClientContext) 29 | 30 | export const GraphqlClientProvider: FC<{ children: ReactNode }> = ({ children }) => { 31 | const router = useRouter() 32 | const { token } = useSession() 33 | 34 | const graphqlClient = useMemo( 35 | () => 36 | new GraphQLClient('/api/wing/graphql', { 37 | headers: { Authorization: `Bearer ${token}` }, 38 | responseMiddleware: (response) => { 39 | const error = (response as ClientError).response?.errors?.[0] 40 | 41 | if (!error) { 42 | return response 43 | } 44 | 45 | if (error.message === 'access denied') { 46 | router.replace('/login') 47 | } else { 48 | toast.error(error?.message) 49 | } 50 | } 51 | }), 52 | [token, router] 53 | ) 54 | 55 | return {children} 56 | } 57 | -------------------------------------------------------------------------------- /src/models/resolvers/shadowsocksr.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from 'js-base64' 2 | import { z } from 'zod' 3 | import { NodeType } from '~/constants' 4 | import { BaseNodeResolver } from '~/models' 5 | import { ssrDefault, ssrSchema } from '~/schemas/node' 6 | 7 | export class ShadowsocksRNodeResolver extends BaseNodeResolver { 8 | type = NodeType.shadowsocksR 9 | schema = ssrSchema 10 | defaultValues = ssrDefault 11 | 12 | generate = (values: z.infer) => 13 | /* ssr://server:port:proto:method:obfs:URLBASE64(password)/?remarks=URLBASE64(remarks)&protoparam=URLBASE64(protoparam)&obfsparam=URLBASE64(obfsparam)) */ 14 | `ssr://${Base64.encode( 15 | `${values.server}:${values.port}:${values.proto}:${values.method}:${values.obfs}:${Base64.encodeURI( 16 | values.password 17 | )}/?remarks=${Base64.encodeURI(values.name)}&protoparam=${Base64.encodeURI( 18 | values.protoParam 19 | )}&obfsparam=${Base64.encodeURI(values.obfsParam)}` 20 | )}` 21 | 22 | resolve(url: string) { 23 | url = Base64.decode(url.substring(6)) 24 | 25 | const arr = url.split('/?') 26 | const query = arr[1].split('&') 27 | 28 | const m: Record = {} 29 | 30 | for (const param of query) { 31 | const pair = param.split('=', 2) 32 | const key = pair[0] 33 | const val = Base64.decode(pair[1]) 34 | 35 | m[key] = val 36 | } 37 | 38 | let pre = arr[0].split(':') 39 | 40 | if (pre.length > 6) { 41 | //如果长度多于6,说明host中包含字符:,重新合并前几个分组到host去 42 | pre[pre.length - 6] = pre.slice(0, pre.length - 5).join(':') 43 | pre = pre.slice(pre.length - 6) 44 | } 45 | 46 | pre[5] = Base64.decode(pre[5]) 47 | 48 | return { 49 | method: pre[3], 50 | password: pre[5], 51 | server: pre[0], 52 | port: pre[1], 53 | name: m['remarks'], 54 | proto: pre[2], 55 | protoParam: m['protoparam'], 56 | obfs: pre[4], 57 | obfsParam: m['obfsparam'] 58 | } as unknown as z.infer 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/models/resolvers/vless.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { NodeType } from '~/constants' 3 | import { BaseNodeResolver } from '~/models' 4 | import { v2rayDefault, v2raySchema } from '~/schemas/node' 5 | 6 | export class VlessNodeResolver extends BaseNodeResolver { 7 | type = NodeType.vless 8 | schema = v2raySchema 9 | defaultValues = v2rayDefault 10 | 11 | generate(values: z.infer) { 12 | const { net, tls, path, host, type, sni, flow, allowInsecure, alpn, id, add, port, ps } = values 13 | 14 | const params: Record = { 15 | type: net, 16 | security: tls, 17 | path, 18 | host, 19 | headerType: type, 20 | sni, 21 | flow, 22 | allowInsecure 23 | } 24 | 25 | if (alpn !== '') params.alpn = alpn 26 | 27 | if (net === 'grpc') params.serviceName = path 28 | 29 | if (net === 'kcp') params.seed = path 30 | 31 | return this.generateURL({ 32 | protocol: 'vless', 33 | username: id, 34 | host: add, 35 | port, 36 | hash: ps, 37 | params 38 | }) 39 | } 40 | 41 | resolve(url: string) { 42 | const u = this.parseURL(url) 43 | 44 | const o: z.infer = { 45 | ps: decodeURIComponent(u.hash), 46 | add: u.host, 47 | port: u.port, 48 | id: decodeURIComponent(u.username), 49 | net: u.params.type || 'tcp', 50 | type: u.params.headerType || 'none', 51 | host: u.params.host || u.params.sni || '', 52 | path: u.params.path || u.params.serviceName || '', 53 | alpn: u.params.alpn || '', 54 | flow: u.params.flow || 'none', 55 | sni: u.params.sni || '', 56 | tls: u.params.security || 'none', 57 | allowInsecure: u.params.allowInsecure || false, 58 | aid: 0, 59 | scy: 'none', 60 | v: '' 61 | } 62 | 63 | if (o.alpn !== '') { 64 | o.alpn = decodeURIComponent(o.alpn) 65 | } 66 | 67 | if (o.net === 'kcp') { 68 | o.path = u.params.seed 69 | } 70 | 71 | return o 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ModalFooter, ModalFooterProps, ModalProps, Modal as NextModal, cn } from '@nextui-org/react' 4 | import { ElementRef, forwardRef } from 'react' 5 | import { useTranslation } from 'react-i18next' 6 | import { Button } from '~/components/Button' 7 | 8 | export const Modal = forwardRef, ModalProps>(({ className, ...props }, ref) => ( 9 | 10 | )) 11 | Modal.displayName = 'Modal' 12 | 13 | export const ModalSubmitFormFooter = forwardRef< 14 | ElementRef, 15 | ModalFooterProps & { 16 | isResetDisabled?: boolean 17 | isSubmitDisabled?: boolean 18 | reset: () => void 19 | isSubmitting?: boolean 20 | } 21 | >(({ isResetDisabled, isSubmitDisabled, reset, isSubmitting, ...props }, ref) => { 22 | const { t } = useTranslation() 23 | 24 | return ( 25 | 26 | 29 | 30 | 33 | 34 | ) 35 | }) 36 | ModalSubmitFormFooter.displayName = 'ModalSubmitFormFooter' 37 | 38 | export const ModalConfirmFormFooter = forwardRef< 39 | ElementRef, 40 | ModalFooterProps & { 41 | isSubmitting?: boolean 42 | onCancel: () => void 43 | onConfirm?: () => Promise 44 | } 45 | >(({ onCancel, onConfirm, isSubmitting, ...props }, ref) => { 46 | const { t } = useTranslation() 47 | 48 | return ( 49 | 50 | 53 | 54 | 57 | 58 | ) 59 | }) 60 | ModalConfirmFormFooter.displayName = 'ModalConfirmFormFooter' 61 | -------------------------------------------------------------------------------- /src/models/resolvers/trojan.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { NodeType } from '~/constants' 3 | import { BaseNodeResolver } from '~/models' 4 | import { trojanDefault, trojanSchema } from '~/schemas/node' 5 | 6 | export class TrojanNodeResolver extends BaseNodeResolver { 7 | type = NodeType.trojan 8 | schema = trojanSchema 9 | defaultValues = trojanDefault 10 | 11 | generate = (values: z.infer) => { 12 | const query: Record = { 13 | allowInsecure: values.allowInsecure 14 | } 15 | 16 | if (values.peer !== '') { 17 | query.sni = values.peer 18 | } 19 | 20 | let protocol = 'trojan' 21 | 22 | if (values.method !== 'origin' || values.obfs !== 'none') { 23 | protocol = 'trojan-go' 24 | query.type = values.obfs === 'none' ? 'original' : 'ws' 25 | 26 | if (values.method === 'shadowsocks') { 27 | query.encryption = `ss;${values.ssCipher};${values.ssPassword}` 28 | } 29 | 30 | if (query.type === 'ws') { 31 | query.host = values.host || '' 32 | query.path = values.path || '/' 33 | } 34 | 35 | delete query.allowInsecure 36 | } 37 | 38 | return this.generateURL({ 39 | protocol, 40 | username: values.password, 41 | host: values.server, 42 | port: values.port, 43 | hash: values.name, 44 | params: query 45 | }) 46 | } 47 | 48 | resolve(url: string) { 49 | const u = this.parseURL(url) 50 | 51 | const o: Record = { 52 | password: decodeURIComponent(u.username), 53 | server: u.host, 54 | port: u.port, 55 | name: decodeURIComponent(u.hash), 56 | peer: u.params.peer || u.params.sni || '', 57 | allowInsecure: u.params.allowInsecure === true || u.params.allowInsecure === '1', 58 | method: 'origin', 59 | obfs: 'none', 60 | ssCipher: 'aes-128-gcm' 61 | } 62 | 63 | if (url.toLowerCase().startsWith('' + '')) { 64 | if (u.params.encryption?.startsWith('ss;')) { 65 | o.method = 'shadowsocks' 66 | const fields = u.params.encryption.split(';') 67 | o.ssCipher = fields[1] 68 | o.ssPassword = fields[2] 69 | } 70 | 71 | const obfsMap = { 72 | original: 'none', 73 | '': 'none', 74 | ws: 'websocket' 75 | } 76 | 77 | o.obfs = obfsMap[(u.params.type as keyof typeof obfsMap) || ''] 78 | 79 | if (o.obfs === 'ws') { 80 | o.obfs = 'websocket' 81 | } 82 | 83 | if (o.obfs === 'websocket') { 84 | o.host = u.params.host || '' 85 | o.path = u.params.path || '/' 86 | } 87 | } 88 | 89 | return o as unknown as z.infer 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /docs/commit-msg-guide.md: -------------------------------------------------------------------------------- 1 | # Semantic Commit Messages 2 | 3 | ## The reasons for these conventions 4 | 5 | - automatic generating of the changelog 6 | - simple navigation through Git history (e.g. ignoring the style changes) 7 | 8 | See how a minor change to your commit message style can make you a better developer. 9 | 10 | ## Format 11 | 12 | ``` 13 | `(): ` 14 | 15 | `` is optional 16 | ``` 17 | 18 | ## Example 19 | 20 | ``` 21 | feat: add hat wobble 22 | ^--^ ^------------^ 23 | | | 24 | | +-> Summary in present tense. 25 | | 26 | +-------> Type: chore, docs, feat, fix, refactor, style, or test. 27 | ``` 28 | 29 | Example `` values: 30 | 31 | - `feat`: (new feature for the user, not a new feature for build script) 32 | - `fix`: (bug fix for the user, not a fix to a build script) 33 | - `docs`: (changes to the documentation) 34 | - `style`: (formatting, missing semi colons, etc; no production code change) 35 | - `refactor`: (refactoring production code, eg. renaming a variable) 36 | - `test`: (adding missing tests, refactoring tests; no production code change) 37 | - `chore`: (updating grunt tasks etc; no production code change, e.g. dependencies upgrade) 38 | - `perf`: (performance improvement change, e.g. better concurrency performance) 39 | - `ci`: (updating CI configuration files and scripts e.g. `.gitHub/workflows/*.yml` ) 40 | 41 | Example `` values: 42 | 43 | - `init` 44 | - `runner` 45 | - `watcher` 46 | - `config` 47 | - `web-server` 48 | - `proxy` 49 | 50 | The `` can be empty (e.g. if the change is a global or difficult to assign to a single component), in which case the parentheses are omitted. In smaller projects such as Karma plugins, the `` is empty. 51 | 52 | ## Message Subject (First Line) 53 | 54 | The first line cannot be longer than `72` characters and should be followed by a blank line. The type and scope should always be lowercase as shown below 55 | 56 | ## Message Body 57 | 58 | use as in the ``, use the imperative, present tense: "change" not "changed" nor "changes". Message body should include motivation for the change and contrasts with previous behavior. 59 | 60 | ## Message footer 61 | 62 | ### Referencing issues 63 | 64 | Closed issues should be listed on a separate line in the footer prefixed with "Closes" keyword as the following: 65 | 66 | ``` 67 | Closes #234 68 | ``` 69 | 70 | or in the case of multiple issues: 71 | 72 | ``` 73 | Closes #123, #245, #992 74 | ``` 75 | 76 | ## References 77 | 78 | - 79 | - 80 | - 81 | - 82 | -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { Button, Input } from '@nextui-org/react' 5 | import RiveComponent from '@rive-app/react-canvas' 6 | import ky, { HTTPError } from 'ky' 7 | import { useRouter } from 'next/navigation' 8 | import { useForm } from 'react-hook-form' 9 | import { useTranslation } from 'react-i18next' 10 | import { toast } from 'sonner' 11 | import { z } from 'zod' 12 | import { LogoText } from '~/components/LogoText' 13 | import { loginFormDefault, loginSchema } from '~/schemas/account' 14 | 15 | export default function LoginPage() { 16 | const { t } = useTranslation() 17 | const router = useRouter() 18 | 19 | const form = useForm>({ 20 | resolver: zodResolver(loginSchema), 21 | defaultValues: loginFormDefault 22 | }) 23 | 24 | return ( 25 |
26 |
27 |
28 | 29 |
30 | 31 | 32 |
33 | 34 |
35 |
{ 37 | try { 38 | await ky.post('/api/login', { json: { username, password } }).json<{ token: string }>() 39 | 40 | router.replace('/network') 41 | } catch (err) { 42 | toast.error((await (err as HTTPError).response.json()).message) 43 | } 44 | })} 45 | > 46 | 55 | 56 | 65 | 66 | 69 |
70 |
71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "daed-revived-next", 3 | "version": "0.1.0", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "next build", 8 | "dev": "next dev", 9 | "format": "prettier -w .", 10 | "lint": "next lint", 11 | "prepare": "husky install", 12 | "start": "next start", 13 | "test:unit": "vitest run" 14 | }, 15 | "dependencies": { 16 | "@commitlint/config-conventional": "^18.4.3", 17 | "@graphql-codegen/cli": "^5.0.0", 18 | "@graphql-codegen/client-preset": "^4.1.0", 19 | "@graphql-codegen/introspection": "^4.0.0", 20 | "@graphql-typed-document-node/core": "^3.2.0", 21 | "@hookform/resolvers": "^3.3.2", 22 | "@monaco-editor/react": "^4.6.0", 23 | "@nextui-org/react": "^2.2.9", 24 | "@parcel/watcher": "^2.3.0", 25 | "@rive-app/react-canvas": "^4.5.4", 26 | "@tabler/icons-react": "^2.42.0", 27 | "@tanstack/react-query": "^5.12.1", 28 | "@tanstack/react-query-devtools": "^5.8.8", 29 | "@types/jsonwebtoken": "^9.0.5", 30 | "@types/lodash": "^4.14.202", 31 | "@types/mime": "^3.0.4", 32 | "@types/node": "^20.10.0", 33 | "@types/react": "^18.2.39", 34 | "@types/react-dom": "^18.2.17", 35 | "@types/react-syntax-highlighter": "^15.5.10", 36 | "@types/urijs": "^1.19.25", 37 | "@vitejs/plugin-react": "^4.2.0", 38 | "autoprefixer": "^10.4.16", 39 | "commitlint": "^18.4.3", 40 | "dayjs": "^1.11.10", 41 | "encoding": "^0.1.13", 42 | "env-paths": "^3.0.0", 43 | "eslint": "^8.54.0", 44 | "eslint-config-next": "14.0.3", 45 | "framer-motion": "^10.16.5", 46 | "geist": "^1.1.0", 47 | "graphql": "^16.8.1", 48 | "graphql-request": "^6.1.0", 49 | "husky": "^8.0.3", 50 | "i18next": "^23.7.7", 51 | "i18next-browser-languagedetector": "^7.2.0", 52 | "js-base64": "^3.7.5", 53 | "jsdom": "^23.0.0", 54 | "jsonwebtoken": "^9.0.2", 55 | "ky": "^1.1.3", 56 | "lint-staged": "^15.1.0", 57 | "lodash": "^4.17.21", 58 | "match-sorter": "^6.3.1", 59 | "mime": "^3.0.0", 60 | "monaco-editor": "^0.44.0", 61 | "monaco-themes": "^0.4.4", 62 | "next": "^14.0.3", 63 | "next-themes": "^0.2.1", 64 | "postcss": "^8.4.31", 65 | "prettier": "^3.1.0", 66 | "prettier-plugin-organize-imports": "^3.2.4", 67 | "prettier-plugin-tailwindcss": "^0.5.7", 68 | "qrcode.react": "^3.1.0", 69 | "react": "^18.2.0", 70 | "react-dom": "^18.2.0", 71 | "react-hook-form": "^7.48.2", 72 | "react-i18next": "^13.5.0", 73 | "react-syntax-highlighter": "^15.5.0", 74 | "react-use": "^17.4.2", 75 | "sonner": "^1.2.3", 76 | "sort-package-json": "^2.6.0", 77 | "tailwindcss": "^3.3.5", 78 | "typescript": "^5.3.2", 79 | "urijs": "^1.19.11", 80 | "vitest": "^0.34.6", 81 | "zod": "^3.22.4", 82 | "zod-i18n-map": "^2.21.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | If you want to contribute to a project and make it better, your help is very welcome. Contributing is also a great way to learn more about social coding on Github, new technologies and and their ecosystems and how to make constructive, helpful bug reports, feature requests and the noblest of all contributions: a good, clean pull request. 4 | 5 | ### Bug Reports and Feature Requests 6 | 7 | If you have found a `bug` or have a `feature request`, please use the search first in case a similar issue already exists. If not, please create an [issue](https://github.com/daeuniverse/dae-wing/issues/new) in this repository 8 | 9 | ### Code 10 | 11 | If you would like to fix a bug or implement a feature, please `fork` the repository and `create a Pull Request`. 12 | 13 | Before you start any Pull Request, `it is recommended that you create an issue` to discuss first if you have any doubts about requirement or implementation. That way you can be sure that the maintainer(s) agree on what to change and how, and you can hopefully get a quick merge afterwards. 14 | 15 | `Pull Requests` can only be merged once all status checks are green. 16 | 17 | ### How to make a clean pull request 18 | 19 | - Create a `personal fork` of the project on Github. 20 | - Clone the fork on your local machine. Your remote repo on Github is called `origin`. 21 | - Add the original repository as a remote called `upstream`. 22 | - If you created your fork a while ago be sure to pull upstream changes into your local repository. 23 | - Create a new branch to work on! Branch from `develop` if it exists, else from `master`. 24 | - Implement/fix your feature, comment your code. 25 | - Follow the code style of the project, including indentation. 26 | - If the project has tests run them! 27 | - Write or adapt tests as needed. 28 | - Add or change the documentation as needed. 29 | - Squash your commits into a single commit with git's [interactive rebase](https://help.github.com/articles/interactive-rebase). Create a new branch if necessary. 30 | - Push your branch to your fork on Github, the remote `origin`. 31 | - From your fork open a pull request in the correct branch. Target the project's `develop` branch if there is one, else go for `master`! 32 | - Once the pull request is approved and merged you can pull the changes from `upstream` to your local repo and delete 33 | your extra branch(es). 34 | 35 | And last but not least: Always write your commit messages in the present tense. Your commit message should describe what the commit, when applied, does to the code – not what you did to the code. 36 | 37 | ### Re-requesting a review 38 | 39 | Please do not ping your reviewer(s) by mentioning them in a new comment. Instead, use the re-request review functionality. Read more about this in the [ GitHub docs, Re-requesting a review ](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request#re-requesting-a-review). 40 | -------------------------------------------------------------------------------- /src/models/base.ts: -------------------------------------------------------------------------------- 1 | import URI from 'urijs' 2 | import { ZodSchema, z } from 'zod' 3 | 4 | import { NodeType } from '~/constants' 5 | 6 | export type GenerateURLParams = { 7 | username?: string 8 | password?: string 9 | protocol: string 10 | host: string 11 | port: number 12 | params?: Record 13 | hash: string 14 | path?: string 15 | } 16 | 17 | export abstract class BaseNodeResolver { 18 | abstract type: NodeType 19 | abstract schema: Schema 20 | abstract defaultValues: z.infer 21 | 22 | abstract generate(values: z.infer): string 23 | abstract resolve(url: string): z.infer 24 | 25 | generateURL = ({ username, password, protocol, host, port, params, hash, path }: GenerateURLParams) => 26 | URI() 27 | .protocol(protocol || 'http') 28 | .username(username || '') 29 | .password(password || '') 30 | .host(host || '') 31 | .port(String(port) || '80') 32 | .path(path || '') 33 | .query(params || {}) 34 | .hash(hash || '') 35 | .toString() 36 | 37 | parseURL(u: string) { 38 | let url = u 39 | let protocol = '' 40 | let fakeProto = false 41 | 42 | if (url.indexOf('://') === -1) { 43 | url = 'http://' + url 44 | } else { 45 | protocol = url.substring(0, url.indexOf('://')) 46 | 47 | switch (protocol) { 48 | case 'http': 49 | case 'https': 50 | case 'ws': 51 | case 'wss': 52 | break 53 | default: 54 | url = 'http' + url.substring(url.indexOf('://')) 55 | fakeProto = true 56 | } 57 | } 58 | 59 | const a = document.createElement('a') 60 | a.href = url 61 | 62 | const r: Record = { 63 | source: u, 64 | username: a.username, 65 | password: a.password, 66 | protocol: fakeProto ? protocol : a.protocol.replace(':', ''), 67 | host: a.hostname, 68 | port: a.port ? parseInt(a.port) : protocol === 'https' || protocol === 'wss' ? 443 : 80, 69 | query: a.search, 70 | params: (function () { 71 | const ret: Record = {}, 72 | seg = a.search.replace(/^\?/, '').split('&'), 73 | len = seg.length 74 | 75 | let i = 0 76 | 77 | let s 78 | 79 | for (; i < len; i++) { 80 | if (!seg[i]) { 81 | continue 82 | } 83 | 84 | s = seg[i].split('=') 85 | ret[s[0]] = decodeURIComponent(s[1]) 86 | } 87 | 88 | return ret 89 | })(), 90 | file: (a.pathname.match(/\/([^/?#]+)$/i) || [null, ''])[1], 91 | hash: a.hash.replace('#', ''), 92 | path: a.pathname.replace(/^([^/])/, '/$1'), 93 | relative: (a.href.match(/tps?:\/\/[^/]+(.+)/) || [null, ''])[1], 94 | segments: a.pathname.replace(/^\//, '').split('/') 95 | } 96 | 97 | a.remove() 98 | 99 | return r 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/apis/gql/fragment-masking.ts: -------------------------------------------------------------------------------- 1 | import { DocumentTypeDecoration, ResultOf, TypedDocumentNode } from '@graphql-typed-document-node/core' 2 | import { FragmentDefinitionNode } from 'graphql' 3 | import { Incremental } from './graphql' 4 | 5 | export type FragmentType> = 6 | TDocumentType extends DocumentTypeDecoration 7 | ? [TType] extends [{ ' $fragmentName'?: infer TKey }] 8 | ? TKey extends string 9 | ? { ' $fragmentRefs'?: { [key in TKey]: TType } } 10 | : never 11 | : never 12 | : never 13 | 14 | // return non-nullable if `fragmentType` is non-nullable 15 | export function useFragment( 16 | _documentNode: DocumentTypeDecoration, 17 | fragmentType: FragmentType> 18 | ): TType 19 | // return nullable if `fragmentType` is nullable 20 | export function useFragment( 21 | _documentNode: DocumentTypeDecoration, 22 | fragmentType: FragmentType> | null | undefined 23 | ): TType | null | undefined 24 | // return array of non-nullable if `fragmentType` is array of non-nullable 25 | export function useFragment( 26 | _documentNode: DocumentTypeDecoration, 27 | fragmentType: ReadonlyArray>> 28 | ): ReadonlyArray 29 | // return array of nullable if `fragmentType` is array of nullable 30 | export function useFragment( 31 | _documentNode: DocumentTypeDecoration, 32 | fragmentType: ReadonlyArray>> | null | undefined 33 | ): ReadonlyArray | null | undefined 34 | export function useFragment( 35 | _documentNode: DocumentTypeDecoration, 36 | fragmentType: 37 | | FragmentType> 38 | | ReadonlyArray>> 39 | | null 40 | | undefined 41 | ): TType | ReadonlyArray | null | undefined { 42 | return fragmentType as any 43 | } 44 | 45 | export function makeFragmentData, FT extends ResultOf>( 46 | data: FT, 47 | _fragment: F 48 | ): FragmentType { 49 | return data as FragmentType 50 | } 51 | export function isFragmentReady( 52 | queryNode: DocumentTypeDecoration, 53 | fragmentNode: TypedDocumentNode, 54 | data: FragmentType, any>> | null | undefined 55 | ): data is FragmentType { 56 | const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__ 57 | ?.deferredFields 58 | 59 | if (!deferredFields) return true 60 | 61 | const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined 62 | const fragName = fragDef?.name?.value 63 | 64 | const fields = (fragName && deferredFields[fragName]) || [] 65 | return fields.length > 0 && fields.every((field) => data && field in data) 66 | } 67 | -------------------------------------------------------------------------------- /src/schemas/config.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const configFormSchema = z.object({ 4 | name: z.string().min(4).max(20), 5 | tproxyPort: z.number().min(0).max(65535), 6 | tproxyPortProtect: z.boolean(), 7 | soMarkFromDae: z.number().min(0), 8 | logLevel: z.string(), 9 | disableWaitingNetwork: z.boolean(), 10 | lanInterface: z.array(z.string()), 11 | wanInterface: z.array(z.string()), 12 | autoConfigKernelParameter: z.boolean(), 13 | tcpCheckUrl: z.array(z.string().min(1)).min(1), 14 | tcpCheckHttpMethod: z.string(), 15 | udpCheckDns: z.array(z.string().min(1)).min(1), 16 | checkIntervalSeconds: z.number().min(0), 17 | checkToleranceMS: z.number().min(0), 18 | dialMode: z.string(), 19 | allowInsecure: z.boolean(), 20 | sniffingTimeoutMS: z.number().min(0), 21 | tlsImplementation: z.string(), 22 | utlsImitate: z.string() 23 | }) 24 | 25 | export enum LogLevel { 26 | error = 'error', 27 | warn = 'warn', 28 | info = 'info', 29 | debug = 'debug', 30 | trace = 'trace' 31 | } 32 | 33 | export enum DialMode { 34 | ip = 'ip', 35 | domain = 'domain', 36 | domainP = 'domain+', 37 | domainPP = 'domain++' 38 | } 39 | 40 | export enum TcpCheckHttpMethod { 41 | CONNECT = 'CONNECT', 42 | HEAD = 'HEAD', 43 | OPTIONS = 'OPTIONS', 44 | TRACE = 'TRACE', 45 | GET = 'GET', 46 | POST = 'POST', 47 | DELETE = 'DELETE', 48 | PATCH = 'PATCH', 49 | PUT = 'PUT' 50 | } 51 | 52 | export enum TLSImplementation { 53 | tls = 'tls', 54 | utls = 'utls' 55 | } 56 | 57 | export enum UTLSImitate { 58 | randomized = 'randomized', 59 | randomizedalpn = 'randomizedalpn', 60 | randomizednoalpn = 'randomizednoalpn', 61 | firefox_auto = 'firefox_auto', 62 | firefox_55 = 'firefox_55', 63 | firefox_56 = 'firefox_56', 64 | firefox_63 = 'firefox_63', 65 | firefox_65 = 'firefox_65', 66 | firefox_99 = 'firefox_99', 67 | firefox_102 = 'firefox_102', 68 | firefox_105 = 'firefox_105', 69 | chrome_auto = 'chrome_auto', 70 | chrome_58 = 'chrome_58', 71 | chrome_62 = 'chrome_62', 72 | chrome_70 = 'chrome_70', 73 | chrome_72 = 'chrome_72', 74 | chrome_83 = 'chrome_83', 75 | chrome_87 = 'chrome_87', 76 | chrome_96 = 'chrome_96', 77 | chrome_100 = 'chrome_100', 78 | chrome_102 = 'chrome_102', 79 | ios_auto = 'ios_auto', 80 | ios_11_1 = 'ios_11_1', 81 | ios_12_1 = 'ios_12_1', 82 | ios_13 = 'ios_13', 83 | ios_14 = 'ios_14', 84 | android_11_okhttp = 'android_11_okhttp', 85 | edge_auto = 'edge_auto', 86 | edge_85 = 'edge_85', 87 | edge_106 = 'edge_106', 88 | safari_auto = 'safari_auto', 89 | safari_16_0 = 'safari_16_0', 90 | utls_360_auto = '360_auto', 91 | utls_360_7_5 = '360_7_5', 92 | utls_360_11_0 = '360_11_0', 93 | qq_auto = 'qq_auto', 94 | qq_11_1 = 'qq_11_1' 95 | } 96 | 97 | export const configFormDefault: z.infer = { 98 | name: '', 99 | tproxyPort: 12345, 100 | tproxyPortProtect: true, 101 | soMarkFromDae: 80, 102 | logLevel: LogLevel.info, 103 | disableWaitingNetwork: true, 104 | lanInterface: [], 105 | wanInterface: [], 106 | autoConfigKernelParameter: true, 107 | tcpCheckUrl: ['http://cp.cloudflare.com', '1.1.1.1', '2606:4700:4700::1111'], 108 | tcpCheckHttpMethod: TcpCheckHttpMethod.HEAD, 109 | udpCheckDns: ['dns.google.com:53', '8.8.8.8', '2001:4860:4860::8888'], 110 | checkIntervalSeconds: 30, 111 | checkToleranceMS: 0, 112 | dialMode: DialMode.domain, 113 | allowInsecure: false, 114 | sniffingTimeoutMS: 100, 115 | tlsImplementation: TLSImplementation.tls, 116 | utlsImitate: UTLSImitate.randomized 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # daed 2 | 3 | A modern web dashboard for dae 4 | 5 | ![pr-closed](https://img.shields.io/github/issues-pr-closed/daeuniverse/daed?style=for-the-badge) 6 | ![last-commit](https://img.shields.io/github/last-commit/daeuniverse/daed?style=for-the-badge) 7 | ![build](https://img.shields.io/github/actions/workflow/status/daeuniverse/daed/release.yml?style=for-the-badge) 8 | ![downloads](https://img.shields.io/github/downloads/daeuniverse/daed/total?style=for-the-badge) 9 | ![license](https://img.shields.io/github/license/daeuniverse/daed?style=for-the-badge) 10 | 11 | ## Preview 12 | 13 | ![preview-login](docs/preview-login.webp) 14 | 15 | ## Features 16 | 17 | - [x] Easy to use, with keyboard navigation / shortcuts builtin 18 | - [x] Beautiful and intuitive UI 19 | - [x] Light / Dark mode 20 | - [x] Mobile friendly 21 | 22 | ## Getting started 23 | 24 | ### Prerequisites 25 | 26 | #### Install the toolchain 27 | 28 | > Git is required to fetch the source code 29 | 30 | - [Git](https://git-scm.com) 31 | 32 | > Install the docker engine if you choose to build and run in docker 33 | 34 | - [Docker](https://www.docker.com) 35 | 36 | > Install the build toolchain if you choose to build from the source files on your own 37 | 38 | - [Golang](https://go.dev), [GNU GCC](https://gcc.gnu.org) (required by dae-wing) 39 | - [Node.js](https://nodejs.org), [pnpm](https://pnpm.io) (required by daed) 40 | 41 | #### Fetch the source code 42 | 43 | > Clone the repository with git submodules (dae-wing) using git 44 | 45 | ```shell 46 | git clone https://github.com/daeuniverse/daed-revived-next.git daed 47 | cd daed 48 | 49 | # Initialize git submodules 50 | git submodule update --init --recursive 51 | ``` 52 | 53 | #### Build and run dae-wing 54 | 55 | > Build dae-wing 56 | 57 | ```shell 58 | cd wing 59 | 60 | make deps 61 | go build -o dae-wing 62 | ``` 63 | 64 | > Run dae-wing with root privileges 65 | 66 | ```shell 67 | sudo ./dae-wing run -c ./ 68 | ``` 69 | 70 | Learn more about dae-wing at [dae-wing](https://github.com/daeuniverse/dae-wing) 71 | 72 | ### Customize daed with `.env.local` file 73 | 74 | Create a `.env.local` file to customize daed 75 | 76 | | Name | Default | Required | Description | 77 | | ------------ | --------------------- | -------- | ------------------------------------ | 78 | | WING_API_URL | http://localhost:2023 | ✅ | Set the API Endpoint URL of dae-wing | 79 | | HOSTNAME | 0.0.0.0 | ⬜ | Set the HTTP Hostname of daed | 80 | | PORT | 3000 | ⬜ | Set the HTTP Port of daed | 81 | 82 | ### Docker 83 | 84 | > Build the docker image 85 | 86 | ```shell 87 | docker build -t daed . 88 | ``` 89 | 90 | > Run the docker image you just build in the background 91 | 92 | ```shell 93 | docker run -d --name daed -p 3000:3000 daed 94 | ``` 95 | 96 | ### From Source 97 | 98 | > Install Dependencies 99 | 100 | ```shell 101 | pnpm install 102 | ``` 103 | 104 | > Build Artifacts 105 | 106 | ```shell 107 | pnpm run build 108 | ``` 109 | 110 | > Run Server 111 | 112 | ```shell 113 | pnpm start 114 | ``` 115 | 116 | ## Contributing 117 | 118 | Feel free to open issues or submit your PR, any feedbacks or help are greatly appreciated. 119 | 120 | Special thanks go to all these amazing people. 121 | 122 | [![contributors](https://contrib.rocks/image?repo=daeuniverse/daed-revived-next)](https://github.com/daeuniverse/daed-revived-next/graphs/contributors) 123 | 124 | If you would like to contribute, please see the [instructions](CONTRIBUTING.md). Also, it is recommended following the [commit message guide](docs/commit-msg-guide.md). 125 | 126 | ## Credits 127 | 128 | - [dae-wing](https://github.com/daeuniverse/dae-wing) 129 | - [Next.JS](https://github.com/vercel/next.js) 130 | - [NextUI](https://github.com/nextui-org/nextui) 131 | -------------------------------------------------------------------------------- /src/models/resolvers/shadowsocks.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from 'js-base64' 2 | import { z } from 'zod' 3 | import { NodeType } from '~/constants' 4 | import { BaseNodeResolver } from '~/models' 5 | import { ssDefault, ssSchema } from '~/schemas/node' 6 | 7 | export class ShadowsocksNodeResolver extends BaseNodeResolver { 8 | type = NodeType.shadowsocks 9 | schema = ssSchema 10 | defaultValues = ssDefault 11 | 12 | generate(values: z.infer) { 13 | /* ss://BASE64(method:password)@server:port#name */ 14 | let link = `ss://${Base64.encode(`${values.method}:${values.password}`)}@${values.server}:${values.port}/` 15 | 16 | if (values.plugin) { 17 | const plugin: string[] = [values.plugin] 18 | 19 | if (values.plugin === 'v2ray-plugin') { 20 | if (values.tls) { 21 | plugin.push('tls') 22 | } 23 | 24 | if (values.mode !== 'websocket') { 25 | plugin.push('mode=' + values.mode) 26 | } 27 | 28 | if (values.host) { 29 | plugin.push('host=' + values.host) 30 | } 31 | 32 | if (values.path) { 33 | if (!values.path.startsWith('/')) { 34 | values.path = '/' + values.path 35 | } 36 | 37 | plugin.push('path=' + values.path) 38 | } 39 | 40 | if (values.impl) { 41 | plugin.push('impl=' + values.impl) 42 | } 43 | } else { 44 | plugin.push('obfs=' + values.obfs) 45 | plugin.push('obfs-host=' + values.host) 46 | 47 | if (values.obfs === 'http') { 48 | plugin.push('obfs-path=' + values.path) 49 | } 50 | 51 | if (values.impl) { 52 | plugin.push('impl=' + values.impl) 53 | } 54 | } 55 | 56 | link += `?plugin=${encodeURIComponent(plugin.join(';'))}` 57 | } 58 | 59 | link += values.name.length ? `#${encodeURIComponent(values.name)}` : '' 60 | 61 | return link 62 | } 63 | 64 | resolve(url: string) { 65 | const u = this.parseURL(url) 66 | 67 | let mp 68 | 69 | if (!u.password) { 70 | try { 71 | u.username = Base64.decode(decodeURIComponent(u.username)) 72 | mp = u.username.split(':') 73 | 74 | if (mp.length > 2) { 75 | mp[1] = mp.slice(1).join(':') 76 | mp = mp.slice(0, 2) 77 | } 78 | } catch (e) { 79 | //pass 80 | } 81 | } else { 82 | mp = [u.username, u.password] 83 | } 84 | 85 | u.hash = decodeURIComponent(u.hash) 86 | 87 | const obj: z.infer = { 88 | method: mp[0], 89 | password: mp[1], 90 | server: u.host, 91 | port: u.port, 92 | name: u.hash, 93 | obfs: 'http', 94 | plugin: '', 95 | impl: '', 96 | path: '', 97 | tls: '', 98 | mode: '', 99 | host: '' 100 | } 101 | 102 | if (u.params.plugin) { 103 | u.params.plugin = decodeURIComponent(u.params.plugin) 104 | 105 | const arr = u.params.plugin.split(';') 106 | 107 | const plugin = arr[0] 108 | 109 | switch (plugin) { 110 | case 'obfs-local': 111 | case 'simpleobfs': 112 | obj.plugin = 'simple-obfs' 113 | break 114 | case 'v2ray-plugin': 115 | obj.tls = '' 116 | obj.mode = 'websocket' 117 | break 118 | } 119 | 120 | for (let i = 1; i < arr.length; i++) { 121 | //"obfs-local;obfs=tls;obfs-host=4cb6a43103.wns.windows.com" 122 | const a = arr[i].split('=') 123 | 124 | switch (a[0]) { 125 | case 'obfs': 126 | obj.obfs = a[1] 127 | break 128 | case 'host': 129 | case 'obfs-host': 130 | obj.host = a[1] 131 | break 132 | case 'path': 133 | case 'obfs-path': 134 | obj.path = a[1] 135 | break 136 | case 'mode': 137 | obj.mode = a[1] 138 | break 139 | case 'tls': 140 | obj.tls = 'tls' 141 | break 142 | case 'impl': 143 | obj.impl = a[1] 144 | } 145 | } 146 | } 147 | 148 | return obj 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/app/api/login/route.ts: -------------------------------------------------------------------------------- 1 | import { ClientError, GraphQLClient } from 'graphql-request' 2 | import { NextResponse } from 'next/server' 3 | import { graphql } from '~/apis/gql' 4 | import { 5 | createConfigMutation, 6 | createDNSMutation, 7 | createGroupMutation, 8 | createRoutingMutation, 9 | selectConfigMutation, 10 | selectDNSMutation, 11 | selectRoutingMutation, 12 | setJsonStorageMutation 13 | } from '~/apis/mutation' 14 | import { graphqlAPIURL } from '~/constants' 15 | import { storeJWTAsCookie } from '~/helpers' 16 | import { configFormDefault } from '~/schemas/config' 17 | import { DNSFormDefault } from '~/schemas/dns' 18 | import { groupFormDefault } from '~/schemas/group' 19 | import { routingFormDefault } from '~/schemas/routing' 20 | 21 | const getDefaults = async >(requestClient: GraphQLClient, paths: string[]) => { 22 | const { jsonStorage } = await requestClient.request( 23 | graphql(` 24 | query JsonStorage($paths: [String!]) { 25 | jsonStorage(paths: $paths) 26 | } 27 | `), 28 | { paths: paths as unknown as string[] } 29 | ) 30 | 31 | return jsonStorage.reduce( 32 | (prev, cur, index) => ({ ...prev, [paths[index]]: cur }), 33 | {} as { [key in T[number]]: (typeof jsonStorage)[number] } 34 | ) 35 | } 36 | 37 | const setJsonStorage = (requestClient: GraphQLClient, object: Record) => { 38 | const paths = Object.keys(object) 39 | const values = paths.map((path) => object[path]) 40 | 41 | return requestClient.request(setJsonStorageMutation, { 42 | paths, 43 | values 44 | }) 45 | } 46 | 47 | const initialize = async (token: string) => { 48 | const requestClient = new GraphQLClient(graphqlAPIURL, { 49 | headers: { 50 | Authorization: `Bearer ${token}` 51 | } 52 | }) 53 | 54 | const { defaultConfigID, defaultRoutingID, defaultDNSID, defaultGroupID } = await getDefaults(requestClient, [ 55 | 'defaultConfigID', 56 | 'defaultDNSID', 57 | 'defaultGroupID', 58 | 'defaultRoutingID' 59 | ]) 60 | 61 | if (!defaultConfigID) { 62 | const { 63 | createConfig: { id } 64 | } = await requestClient.request(createConfigMutation, { 65 | name: 'global', 66 | global: configFormDefault 67 | }) 68 | 69 | await requestClient.request(selectConfigMutation, { id }) 70 | await setJsonStorage(requestClient, { defaultConfigID: id }) 71 | } 72 | 73 | if (!defaultRoutingID) { 74 | const { 75 | createRouting: { id } 76 | } = await requestClient.request(createRoutingMutation, { ...routingFormDefault, name: 'routing' }) 77 | 78 | await requestClient.request(selectRoutingMutation, { id }) 79 | await setJsonStorage(requestClient, { defaultRoutingID: id }) 80 | } 81 | 82 | if (!defaultDNSID) { 83 | const { 84 | createDns: { id } 85 | } = await requestClient.request(createDNSMutation, { ...DNSFormDefault, name: 'dns' }) 86 | 87 | await requestClient.request(selectDNSMutation, { id }) 88 | await setJsonStorage(requestClient, { defaultDNSID: id }) 89 | } 90 | 91 | if (!defaultGroupID) { 92 | const { 93 | createGroup: { id } 94 | } = await requestClient.request(createGroupMutation, { 95 | ...groupFormDefault, 96 | name: 'proxy' 97 | }) 98 | await setJsonStorage(requestClient, { defaultGroupID: id }) 99 | } 100 | } 101 | 102 | export const POST = async (req: Request) => { 103 | const requestClient = new GraphQLClient(graphqlAPIURL, { 104 | responseMiddleware: (response) => { 105 | const error = (response as ClientError).response?.errors?.[0] 106 | 107 | if (!error) { 108 | return response 109 | } 110 | 111 | throw error 112 | } 113 | }) 114 | 115 | const { username, password } = await req.json() 116 | 117 | const { numberUsers } = await requestClient.request( 118 | graphql(` 119 | query NumberUsers { 120 | numberUsers 121 | } 122 | `) 123 | ) 124 | 125 | // If there are no users, create one 126 | // and initialize the default config, routing, dns, and group 127 | if (numberUsers === 0) { 128 | const { createUser } = await requestClient.request( 129 | graphql(` 130 | mutation CreateUser($username: String!, $password: String!) { 131 | createUser(username: $username, password: $password) 132 | } 133 | `), 134 | { username, password } 135 | ) 136 | 137 | await initialize(createUser) 138 | 139 | storeJWTAsCookie(createUser) 140 | 141 | return new NextResponse() 142 | } 143 | 144 | try { 145 | const { token } = await requestClient.request( 146 | graphql(` 147 | query Token($username: String!, $password: String!) { 148 | token(username: $username, password: $password) 149 | } 150 | `), 151 | { username, password } 152 | ) 153 | 154 | storeJWTAsCookie(token) 155 | 156 | return new NextResponse() 157 | } catch (err) { 158 | return NextResponse.json({ message: (err as Error).message }, { status: 401 }) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/i18n/locales/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": { 3 | "cancel": "取消", 4 | "confirm": "确定", 5 | "darkMode": "暗黑", 6 | "edit": "编辑", 7 | "lightMode": "明亮", 8 | "loading": "正在加载{{resourceName}}...", 9 | "login": "登录", 10 | "logout": "登出", 11 | "refresh": "刷新", 12 | "remove": "移除", 13 | "reset": "重置", 14 | "select": "选择", 15 | "submit": "提交", 16 | "switchLanguage": "切换语言", 17 | "switchTheme": "切换主题", 18 | "systemMode": "系统", 19 | "update": "修改{{resourceName}}" 20 | }, 21 | "form": { 22 | "descriptions": { 23 | "allowInsecure": "允许使用不安全的TLS证书。除非迫不得已,否则不建议打开它", 24 | "autoConfigKernelParameter": "自动配置 Linux 内核参数,如 ip_forward(路由转发)和 send_redirects(发送重定向)", 25 | "checkTolerance": "只有当 新的延迟 <= (旧的延迟 - 公差) 时,群组才会切换节点", 26 | "disableWaitingNetwork": "禁用在拉取订阅之前等待网络", 27 | "group": { 28 | "Min": "从组中为每个连接选择最后延迟最小的节点", 29 | "MinAvg10": "从组中为每个连接选择最近 10 个延迟的最小平均值的节点", 30 | "MinMovingAvg": "从组中为每个连接选择具有最小移动平均延迟的节点", 31 | "Random": "为每个连接从组中随机选择一个节点" 32 | }, 33 | "lanInterface": "要绑定的 LAN 接口。如果您想代理局域网,请使用它", 34 | "pleaseEnter": "请输入您的{{fieldName}}", 35 | "pleaseSelect": "请选择您的{{fieldName}}", 36 | "sniffingTimeout": "等待第一次发送数据以进行嗅探时超时。如果拨号模式为 ip,则始终为 0。将其设置得更高在高延迟局域网网络中很有用", 37 | "soMarkFromDae": "如果不是 0,则从 dae 发送的流量将被设置套接字标记(SO_MARK)。这对于使用 iptables/nftables 透明代理规则避免流量循环非常有用", 38 | "tcpCheckHttpMethod": "对 TCP 检测链接的 HTTP 请求方法。默认情况下使用 HEAD,因为某些服务器实现绕过了对此类流量的核算", 39 | "tcpCheckUrl": "如果您在本地有 Dual Stack(双协议栈),URL 的域名解析应该同时具有 IPV4 和 IPV6。第一个是 URL,其它是 IP 地址(如果给定的话)。考虑到流量消耗,建议选择具有 Anycast IP 且响应较少的站点", 40 | "tlsImplementation": "TLS 实现。tls 是使用 GO 的 crypto/tls。utls 是使用 utls,它可以模仿浏览器的客户端 Hello", 41 | "tproxyPort": "要监听的透明代理端口。合法范围是 0 - 65535。它不是 HTTP/SOCKS 端口,仅由 eBPF 程序使用。在正常情况下,您不需要使用它", 42 | "tproxyPortProtect": "将其设置为 true 可保护透明代理端口免受未经请求的流量的影响。将其设置为 false 以允许用户使用自我管理的 iptables 透明代理规则", 43 | "udpCheckDns": "此 DNS 将用于检查节点的 UDP 连接。如果下面的 DNS 上游包含 TCP,它也可以用于检查节点的 TCP DNS 连接。第一个是 URL,其它是 IP 地址(如果给定的话)。如果您在本地有 Dual Stack(双协议栈),则此 DNS 应该同时具有 IPV4 和 IPV6", 44 | "utlsImitate": "要模仿的 uTLS 的客户端 Hello ID。只有当 TLS 实现 为 utls 时,此操作才会生效", 45 | "wanInterface": "要绑定的 WAN 接口。如果您想代理本机,请使用它" 46 | }, 47 | "errors": { 48 | "passwordDontMatch": "密码不匹配" 49 | }, 50 | "fields": { 51 | "allowInsecure": "允许不安全", 52 | "autoConfigKernelParameter": "自动配置内核参数", 53 | "checkInterval": "检测间隔", 54 | "checkTolerance": "检测公差", 55 | "confirmPassword": "确认密码", 56 | "currentPassword": "当前密码", 57 | "dialMode": "拨号模式", 58 | "dialModes": { 59 | "domain": "通过嗅探使用域名拨号代理。如果 DNS 环境不纯净,这将在很大程度上缓解 DNS 污染问题。通常,这种模式会带来更快的代理响应时间,因为代理会在远程重新解析域名,从而获得更好的 IP 连接结果。此策略不影响路由。也就是说,域重写将在流量拆分后进行路由,dae 不会对其进行重新路由", 60 | "domain+": "基于 domain 模式但不检查嗅探域名的真实性。对于那些 DNS 请求不通过 dae 但想要更快的代理响应时间的用户来说,这很有用。请注意,如果 DNS 请求不通过 dae,dae 就无法按域划分流量", 61 | "domain++": "基于 domain+ 模式,但强制使用嗅探域重新路由流量,以部分恢复基于域的流量拆分能力。它不适用于直接流量,并且会消耗更多的 CPU 资源", 62 | "ip": "直接使用来自 DNS 的 IP 拨号代理。这允许您的 IPV4、IPV6 分别选择最佳路径,并使应用程序请求的 IP 版本符合预期。例如,如果您使用 curl -4 ip.sb,您将通过代理请求 IPv4 并获得 IPv4 回显。curl -6 ip.sb 将请求 IPV6。如果你是你的节点支持的话,这可能会解决一些怪异的全锥形问题。在此模式下将禁用嗅探" 63 | }, 64 | "disableWaitingNetwork": "禁用网络等待", 65 | "lanInterface": "LAN 接口", 66 | "link": "链接", 67 | "logLevel": "日志等级", 68 | "logLevels": { 69 | "debug": "调试", 70 | "error": "错误", 71 | "info": "信息", 72 | "trace": "追踪", 73 | "warn": "警告" 74 | }, 75 | "name": "名称", 76 | "newPassword": "新密码", 77 | "password": "密码", 78 | "sniffingTimeout": "嗅探超时", 79 | "soMarkFromDae": "为 dae 设置套接字标记", 80 | "tag": "标签", 81 | "tcpCheckHttpMethod": "TCP 检测 HTTP 方式", 82 | "tcpCheckUrl": "TCP 检测链接", 83 | "tlsImplementation": "TLS 实现", 84 | "tproxyPort": "透明代理端口", 85 | "tproxyPortProtect": "透明代理端口保护", 86 | "udpCheckDns": "UDP 检测 DNS", 87 | "username": "用户名", 88 | "utlsImitate": "uTLS 模仿", 89 | "wanInterface": "WAN 接口" 90 | } 91 | }, 92 | "primitives": { 93 | "accountName": "用户名:{{accountName}}", 94 | "accountSettings": "账户设置", 95 | "action": "动作", 96 | "address": "地址", 97 | "autoDetect": "自动检测", 98 | "chineseSimplified": "简中", 99 | "config": "配置", 100 | "connectingOptions": "连接选项", 101 | "create": "创建{{resourceName}}", 102 | "default": "默认", 103 | "dns": "域名解析", 104 | "endpointURL": "接口地址", 105 | "english": "英文", 106 | "general": "常规", 107 | "group": "群组", 108 | "interfaceAndKernelOptions": "接口及内核选项", 109 | "millisecond": "毫秒", 110 | "name": "名称", 111 | "network": "网络", 112 | "node": "节点", 113 | "nodeConnectivityCheck": "节点连通性检测", 114 | "policy": "策略", 115 | "profile": "个人资料", 116 | "protocol": "协议", 117 | "remove": "移除{{resourceName}}", 118 | "routing": "路由", 119 | "rule": "规则", 120 | "second": "秒", 121 | "settings": "设置", 122 | "softwareOptions": "软件选项", 123 | "subscription": "订阅", 124 | "tag": "标签", 125 | "updatedAt": "更新时间", 126 | "username": "登录账户:{{username}}" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/schemas/node.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const nodeFormSchema = z.object({ 4 | nodes: z 5 | .array( 6 | z.object({ 7 | tag: z.string().min(1), 8 | link: z.string().min(1) 9 | }) 10 | ) 11 | .min(1) 12 | }) 13 | 14 | export const nodeFormDefault: z.infer = { 15 | nodes: [{ tag: '', link: '' }] 16 | } 17 | 18 | export const v2raySchema = z.object({ 19 | ps: z.string(), 20 | add: z.string().min(1), 21 | port: z.number().min(0).max(65535), 22 | id: z.string().min(1), 23 | aid: z.number().min(0).max(65535), 24 | net: z.enum(['tcp', 'kcp', 'ws', 'h2', 'grpc']), 25 | type: z.enum(['none', 'http', 'srtp', 'utp', 'wechat-video', 'dtls', 'wireguard']), 26 | host: z.string(), 27 | path: z.string(), 28 | tls: z.enum(['none', 'tls']), 29 | flow: z.enum(['none', 'xtls-rprx-origin', 'xtls-rprx-origin-udp443', 'xtls-rprx-vision', 'xtls-rprx-vision-udp443']), 30 | alpn: z.string(), 31 | scy: z.enum(['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero']), 32 | v: z.literal(''), 33 | allowInsecure: z.boolean(), 34 | sni: z.string() 35 | }) 36 | 37 | export const v2rayDefault: z.infer = { 38 | type: 'none', 39 | tls: 'none', 40 | net: 'tcp', 41 | scy: 'auto', 42 | add: '', 43 | aid: 0, 44 | allowInsecure: false, 45 | alpn: '', 46 | flow: 'none', 47 | host: '', 48 | id: '', 49 | path: '', 50 | port: 0, 51 | ps: '', 52 | v: '', 53 | sni: '' 54 | } 55 | 56 | export const ssSchema = z.object({ 57 | method: z.enum(['aes-128-gcm', 'aes-256-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305', 'plain', 'none']), 58 | plugin: z.enum(['', 'simple-obfs', 'v2ray-plugin']), 59 | obfs: z.enum(['http', 'tls']), 60 | tls: z.enum(['', 'tls']), 61 | path: z.string(), 62 | mode: z.string(), 63 | host: z.string(), 64 | password: z.string().min(1), 65 | server: z.string().min(1), 66 | port: z.number().min(0).max(65535), 67 | name: z.string(), 68 | impl: z.enum(['', 'chained', 'transport']) 69 | }) 70 | 71 | export const ssDefault: z.infer = { 72 | plugin: '', 73 | method: 'aes-128-gcm', 74 | obfs: 'http', 75 | host: '', 76 | impl: '', 77 | mode: '', 78 | name: '', 79 | password: '', 80 | path: '', 81 | port: 0, 82 | server: '', 83 | tls: '' 84 | } 85 | 86 | export const ssrSchema = z.object({ 87 | method: z.enum([ 88 | 'aes-128-cfb', 89 | 'aes-192-cfb', 90 | 'aes-256-cfb', 91 | 'aes-128-ctr', 92 | 'aes-192-ctr', 93 | 'aes-256-ctr', 94 | 'aes-128-ofb', 95 | 'aes-192-ofb', 96 | 'aes-256-ofb', 97 | 'des-cfb', 98 | 'bf-cfb', 99 | 'cast5-cfb', 100 | 'rc4-md5', 101 | 'chacha20-ietf', 102 | 'salsa20', 103 | 'camellia-128-cfb', 104 | 'camellia-192-cfb', 105 | 'camellia-256-cfb', 106 | 'idea-cfb', 107 | 'rc2-cfb', 108 | 'seed-cfb', 109 | 'none' 110 | ]), 111 | password: z.string().min(1), 112 | server: z.string().min(1), 113 | port: z.number().min(0).max(65535).positive(), 114 | name: z.string(), 115 | proto: z.enum([ 116 | 'origin', 117 | 'verify_sha1', 118 | 'auth_sha1_v4', 119 | 'auth_aes128_md5', 120 | 'auth_aes128_sha1', 121 | 'auth_chain_a', 122 | 'auth_chain_b' 123 | ]), 124 | protoParam: z.string(), 125 | obfs: z.enum(['plain', 'http_simple', 'http_post', 'random_head', 'tls1.2_ticket_auth']), 126 | obfsParam: z.string() 127 | }) 128 | 129 | export const ssrDefault: z.infer = { 130 | method: 'aes-128-cfb', 131 | proto: 'origin', 132 | obfs: 'plain', 133 | name: '', 134 | obfsParam: '', 135 | password: '', 136 | port: 0, 137 | protoParam: '', 138 | server: '' 139 | } 140 | 141 | export const trojanSchema = z.object({ 142 | name: z.string(), 143 | server: z.string().min(1), 144 | peer: z.string(), 145 | host: z.string(), 146 | path: z.string(), 147 | allowInsecure: z.boolean(), 148 | port: z.number().min(0).max(65535), 149 | password: z.string().min(1), 150 | method: z.enum(['origin', 'shadowsocks']), 151 | ssCipher: z.enum(['aes-128-gcm', 'aes-256-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305']), 152 | ssPassword: z.string(), 153 | obfs: z.enum(['none', 'websocket']) 154 | }) 155 | 156 | export const trojanDefault: z.infer = { 157 | method: 'origin', 158 | obfs: 'none', 159 | allowInsecure: false, 160 | host: '', 161 | name: '', 162 | password: '', 163 | path: '', 164 | peer: '', 165 | port: 0, 166 | server: '', 167 | ssCipher: 'aes-128-gcm', 168 | ssPassword: '' 169 | } 170 | 171 | export const tuicSchema = z.object({ 172 | name: z.string(), 173 | server: z.string().min(1), 174 | port: z.number().min(0).max(65535), 175 | uuid: z.string().min(1), 176 | password: z.string().min(1), 177 | allowInsecure: z.boolean(), 178 | disable_sni: z.boolean(), 179 | sni: z.string(), 180 | congestion_control: z.string(), 181 | alpn: z.string(), 182 | udp_relay_mode: z.string() 183 | }) 184 | 185 | export const tuicDefault: z.infer = { 186 | name: '', 187 | port: 0, 188 | server: '', 189 | alpn: '', 190 | congestion_control: '', 191 | disable_sni: false, 192 | allowInsecure: false, 193 | uuid: '', 194 | password: '', 195 | udp_relay_mode: '', 196 | sni: '' 197 | } 198 | 199 | export const juicitySchema = z.object({ 200 | name: z.string(), 201 | server: z.string().min(1), 202 | port: z.number().min(0).max(65535), 203 | uuid: z.string().min(1), 204 | password: z.string().min(1), 205 | allowInsecure: z.boolean(), 206 | sni: z.string(), 207 | congestion_control: z.string() 208 | }) 209 | 210 | export const juicityDefault: z.infer = { 211 | name: '', 212 | port: 0, 213 | server: '', 214 | congestion_control: '', 215 | allowInsecure: false, 216 | uuid: '', 217 | password: '', 218 | sni: '' 219 | } 220 | 221 | export const httpSchema = z.object({ 222 | username: z.string(), 223 | password: z.string(), 224 | host: z.string().min(1), 225 | port: z.number().min(0).max(65535), 226 | name: z.string() 227 | }) 228 | 229 | export const httpDefault: z.infer = { 230 | host: '', 231 | name: '', 232 | password: '', 233 | port: 0, 234 | username: '' 235 | } 236 | 237 | export const socks5Schema = z.object({ 238 | username: z.string(), 239 | password: z.string(), 240 | host: z.string().min(1), 241 | port: z.number().min(0).max(65535), 242 | name: z.string() 243 | }) 244 | 245 | export const socks5Default: z.infer = { 246 | host: '', 247 | name: '', 248 | password: '', 249 | port: 0, 250 | username: '' 251 | } 252 | -------------------------------------------------------------------------------- /src/i18n/locales/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": { 3 | "cancel": "Cancel", 4 | "confirm": "Confirm", 5 | "darkMode": "Dark", 6 | "edit": "Edit", 7 | "lightMode": "Light", 8 | "loading": "Loading {{resourceName}}...", 9 | "login": "Login", 10 | "logout": "Logout", 11 | "refresh": "Refresh", 12 | "remove": "Remove", 13 | "reset": "Reset", 14 | "select": "Select", 15 | "submit": "Submit", 16 | "switchLanguage": "Switch Language", 17 | "switchTheme": "Switch Theme", 18 | "systemMode": "System", 19 | "update": "Update {{resourceName}}" 20 | }, 21 | "form": { 22 | "descriptions": { 23 | "allowInsecure": "Allow insecure TLS certificates. It is not recommended to turn it on unless you have to.", 24 | "autoConfigKernelParameter": "Automatically configure Linux kernel parameters like ip_forward and send_redirects", 25 | "checkTolerance": "Group will switch node only when new_latency <= (old_latency - tolerance)", 26 | "disableWaitingNetwork": "Disable waiting for network before pulling subscriptions", 27 | "group": { 28 | "Min": "Select the node with min last latency from the group for every connection", 29 | "MinAvg10": "Select the node with min average of the last 10 latencies from the group for every connection", 30 | "MinMovingAvg": "Select the node with min moving average of latencies from the group for every connection", 31 | "Random": "Randomly select a node from the group for every connection" 32 | }, 33 | "lanInterface": "The LAN interface to bind. Use it if you want to proxy LAN", 34 | "pleaseEnter": "Please enter your {{fieldName}}", 35 | "pleaseSelect": "Please select your {{fieldName}}", 36 | "sniffingTimeout": "Timeout to waiting for first data sending for sniffing. It is always 0 if dial_mode is ip. Set it higher is useful in high latency LAN network", 37 | "soMarkFromDae": "If not zero, traffic sent from dae will be set SO_MARK. It is useful to avoid traffic loop with iptables/nftables tproxy rules", 38 | "tcpCheckHttpMethod": "The HTTP request method to TCP Check HTTP Method. Use HEAD by default because some server implementations bypass accounting for this kind of traffic", 39 | "tcpCheckUrl": "Host of URL should have both IPV4 and IPV6 if you have Dual Stack in local. First is URL, others are IP addresses if given. Considering traffic consumption, it is recommended to choose a site with Anycast IP and less response", 40 | "tlsImplementation": "TLS implementation. tls is to use GO's crypto/tls. utls is to use uTLS, which can imitate browser's Client Hello", 41 | "tproxyPort": "Transparent Proxy Port to listen on. Valid range is 0 - 65535. It is NOT a HTTP/SOCKS port, and is just used by eBPF program. In normal case, you do not need to use it", 42 | "tproxyPortProtect": "Set it true to protect tproxy port from unsolicited traffic. Set it false to allow users to use self-managed iptables tproxy rules", 43 | "udpCheckDns": "This DNS will be used to check UDP connectivity of nodes. And if dns_upstream below contains tcp, it also be used to check TCP DNS connectivity of nodes. First is URL, others are IP addresses if given. This DNS should have both IPV4 and IPV6 if you have Dual Stack in local", 44 | "utlsImitate": "The Client Hello ID for uTLS to imitate. This takes effect only if tls_implementation is utls", 45 | "wanInterface": "The WAN interface to bind. Use it if you want to proxy localhost" 46 | }, 47 | "errors": { 48 | "passwordDontMatch": "密码不匹配" 49 | }, 50 | "fields": { 51 | "allowInsecure": "Allow Insecure", 52 | "autoConfigKernelParameter": "Auto Config Kernel Parameter", 53 | "checkInterval": "Check Interval", 54 | "checkTolerance": "Check Tolerance", 55 | "confirmPassword": "Confirm Password", 56 | "currentPassword": "Current Password", 57 | "dialMode": "Dial Mode", 58 | "dialModes": { 59 | "domain": "Dial proxy using the domain from sniffing. This will relieve DNS pollution problem to a great extent if have impure DNS environment. Generally, this mode brings faster proxy response time because proxy will re-resolve the domain in remote, thus get better IP result to connect. This policy does not impact routing. That is to say, domain rewrite will be after traffic split of routing and dae will not re-route it.", 60 | "domain+": "Based on domain mode but do not check the reality of sniffed domain. It is useful for users whose DNS requests do not go through dae but want faster proxy response time. Notice that, if DNS requests do not go through dae, dae cannot split traffic by domain.", 61 | "domain++": "Based on domain+ mode but force to re-route traffic using sniffed domain to partially recover domain based traffic split ability. It doesn't work for direct traffic and consumes more CPU resources.", 62 | "ip": "Dial proxy using the IP from DNS directly. This allows your IPV4, IPV6 to choose the optimal path respectively, and makes the IP version requested by the application meet expectations. For example, if you use curl -4 ip.sb, you will request IPV4 via proxy and get a IPV4 echo. And curl -6 ip.sb will request IPV6. This may solve some weird full-cone problem if your are be your node support that. Sniffing will be disabled in this mode." 63 | }, 64 | "disableWaitingNetwork": "Disable Waiting Network", 65 | "lanInterface": "LAN Interface", 66 | "link": "Link", 67 | "logLevel": "Log Level", 68 | "logLevels": { 69 | "debug": "debug", 70 | "error": "error", 71 | "info": "info", 72 | "trace": "trace", 73 | "warn": "warn" 74 | }, 75 | "name": "Name", 76 | "newPassword": "New Password", 77 | "password": "Password", 78 | "sniffingTimeout": "Sniffing Timeout", 79 | "soMarkFromDae": "Set SO_MARK For dae", 80 | "tag": "Tag", 81 | "tcpCheckHttpMethod": "TCP Check HTTP Method", 82 | "tcpCheckUrl": "TCP Check URL", 83 | "tlsImplementation": "TLS Implementation", 84 | "tproxyPort": "Transparent Proxy Port", 85 | "tproxyPortProtect": "Transparent Proxy Port Protect", 86 | "udpCheckDns": "UDP Check DNS", 87 | "username": "Username", 88 | "utlsImitate": "uTLS Imitate", 89 | "wanInterface": "WAN Interface" 90 | } 91 | }, 92 | "primitives": { 93 | "accountName": "Account Name: {{accountName}}", 94 | "accountSettings": "Account Settings", 95 | "action": "Action", 96 | "address": "Address", 97 | "autoDetect": "Auto Detect", 98 | "chineseSimplified": "Chinese Simplified", 99 | "config": "Config", 100 | "connectingOptions": "Connecting Options", 101 | "create": "Create {{resourceName}}", 102 | "default": "Default", 103 | "dns": "DNS", 104 | "endpointURL": "Endpoint URL", 105 | "english": "English", 106 | "general": "General", 107 | "group": "Group", 108 | "interfaceAndKernelOptions": "Interface and Kernel Options", 109 | "millisecond": "ms", 110 | "name": "Name", 111 | "network": "Network", 112 | "node": "Node", 113 | "nodeConnectivityCheck": "Node Connectivity Check", 114 | "policy": "Policy", 115 | "profile": "Profile", 116 | "protocol": "Protocol", 117 | "remove": "Remove {{resourceName}}", 118 | "routing": "Routing", 119 | "rule": "Rule", 120 | "second": "s", 121 | "settings": "Settings", 122 | "softwareOptions": "Software Options", 123 | "subscription": "Subscription", 124 | "tag": "Tag", 125 | "updatedAt": "Updated At", 126 | "username": "Username: {{username}}" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/apis/query.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { graphql } from '~/apis/gql/gql' 3 | import { useGraphqlClient } from '~/contexts' 4 | 5 | export const useGetJSONStorageRequest = >(paths: T) => { 6 | const gqlClient = useGraphqlClient() 7 | 8 | return useQuery({ 9 | queryKey: ['jsonStorage', paths], 10 | queryFn: async () => { 11 | const { jsonStorage } = await gqlClient.request( 12 | graphql(` 13 | query JsonStorage($paths: [String!]) { 14 | jsonStorage(paths: $paths) 15 | } 16 | `), 17 | { paths: paths as unknown as string[] } 18 | ) 19 | 20 | return jsonStorage.reduce( 21 | (prev, cur, index) => ({ ...prev, [paths[index]]: cur }), 22 | {} as { [key in T[number]]: (typeof jsonStorage)[number] } 23 | ) 24 | } 25 | }) 26 | } 27 | 28 | export const generalQueryKey = ['general'] 29 | export const useGeneralQuery = () => { 30 | const gqlClient = useGraphqlClient() 31 | 32 | return useQuery({ 33 | queryKey: generalQueryKey, 34 | queryFn: () => 35 | gqlClient.request( 36 | graphql(` 37 | query General($interfacesUp: Boolean) { 38 | general { 39 | dae { 40 | running 41 | modified 42 | version 43 | } 44 | 45 | interfaces(up: $interfacesUp) { 46 | name 47 | ifindex 48 | ip 49 | flag { 50 | default { 51 | ipVersion 52 | gateway 53 | source 54 | } 55 | } 56 | } 57 | } 58 | } 59 | `), 60 | { interfacesUp: true } 61 | ) 62 | }) 63 | } 64 | 65 | export const nodesQueryKey = ['nodes'] 66 | 67 | export const useNodesQuery = () => { 68 | const gqlClient = useGraphqlClient() 69 | 70 | return useQuery({ 71 | queryKey: nodesQueryKey, 72 | queryFn: () => 73 | gqlClient.request( 74 | graphql(` 75 | query Nodes { 76 | nodes { 77 | edges { 78 | id 79 | name 80 | link 81 | address 82 | protocol 83 | tag 84 | subscriptionID 85 | } 86 | } 87 | } 88 | `) 89 | ) 90 | }) 91 | } 92 | 93 | export const subscriptionsQueryKey = ['subscriptions'] 94 | 95 | export const useSubscriptionsQuery = () => { 96 | const gqlClient = useGraphqlClient() 97 | 98 | return useQuery({ 99 | queryKey: subscriptionsQueryKey, 100 | queryFn: () => 101 | gqlClient.request( 102 | graphql(` 103 | query Subscriptions { 104 | subscriptions { 105 | id 106 | tag 107 | status 108 | link 109 | info 110 | updatedAt 111 | nodes { 112 | edges { 113 | id 114 | name 115 | address 116 | protocol 117 | link 118 | } 119 | } 120 | } 121 | } 122 | `) 123 | ) 124 | }) 125 | } 126 | 127 | export const configsQueryKey = ['configs'] 128 | 129 | export const useConfigsQuery = () => { 130 | const gqlClient = useGraphqlClient() 131 | 132 | return useQuery({ 133 | queryKey: configsQueryKey, 134 | queryFn: () => 135 | gqlClient.request( 136 | graphql(` 137 | query Configs { 138 | configs { 139 | id 140 | name 141 | selected 142 | global { 143 | logLevel 144 | tproxyPort 145 | allowInsecure 146 | checkInterval 147 | checkTolerance 148 | lanInterface 149 | wanInterface 150 | udpCheckDns 151 | tcpCheckUrl 152 | dialMode 153 | tcpCheckHttpMethod 154 | disableWaitingNetwork 155 | autoConfigKernelParameter 156 | sniffingTimeout 157 | tlsImplementation 158 | utlsImitate 159 | tproxyPortProtect 160 | soMarkFromDae 161 | } 162 | } 163 | } 164 | `) 165 | ) 166 | }) 167 | } 168 | 169 | export const groupsQueryKey = ['groups'] 170 | 171 | export const useGroupsQuery = () => { 172 | const gqlClient = useGraphqlClient() 173 | 174 | return useQuery({ 175 | queryKey: groupsQueryKey, 176 | queryFn: () => 177 | gqlClient.request( 178 | graphql(` 179 | query Groups { 180 | groups { 181 | id 182 | name 183 | nodes { 184 | id 185 | link 186 | name 187 | address 188 | protocol 189 | tag 190 | subscriptionID 191 | } 192 | subscriptions { 193 | id 194 | updatedAt 195 | tag 196 | link 197 | status 198 | info 199 | 200 | nodes { 201 | edges { 202 | id 203 | link 204 | name 205 | address 206 | protocol 207 | tag 208 | subscriptionID 209 | } 210 | } 211 | } 212 | policy 213 | policyParams { 214 | key 215 | val 216 | } 217 | } 218 | } 219 | `) 220 | ) 221 | }) 222 | } 223 | 224 | export const routingsQueryKey = ['rules'] 225 | 226 | export const useRoutingsQuery = () => { 227 | const gqlClient = useGraphqlClient() 228 | 229 | return useQuery({ 230 | queryKey: routingsQueryKey, 231 | queryFn: () => 232 | gqlClient.request( 233 | graphql(` 234 | query Routings { 235 | routings { 236 | id 237 | name 238 | selected 239 | routing { 240 | string 241 | } 242 | } 243 | } 244 | `) 245 | ) 246 | }) 247 | } 248 | 249 | export const dnssQueryKey = ['dnss'] 250 | 251 | export const useDNSsQuery = () => { 252 | const gqlClient = useGraphqlClient() 253 | 254 | return useQuery({ 255 | queryKey: dnssQueryKey, 256 | queryFn: () => 257 | gqlClient.request( 258 | graphql(` 259 | query DNSs { 260 | dnss { 261 | id 262 | name 263 | selected 264 | dns { 265 | string 266 | 267 | upstream { 268 | key 269 | val 270 | } 271 | 272 | routing { 273 | request { 274 | string 275 | } 276 | 277 | response { 278 | string 279 | } 280 | } 281 | } 282 | } 283 | } 284 | `) 285 | ) 286 | }) 287 | } 288 | 289 | export const userQueryKey = ['user'] 290 | 291 | export const useUserQuery = () => { 292 | const gqlClient = useGraphqlClient() 293 | 294 | return useQuery({ 295 | queryKey: userQueryKey, 296 | queryFn: () => 297 | gqlClient.request( 298 | graphql(` 299 | query User { 300 | user { 301 | username 302 | name 303 | avatar 304 | } 305 | } 306 | `) 307 | ) 308 | }) 309 | } 310 | -------------------------------------------------------------------------------- /src/app/(protected)/network/NodeSection.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod' 2 | import { 3 | Input, 4 | ModalBody, 5 | ModalContent, 6 | ModalHeader, 7 | Snippet, 8 | Table, 9 | TableBody, 10 | TableCell, 11 | TableColumn, 12 | TableHeader, 13 | TableRow, 14 | getKeyValue, 15 | useDisclosure 16 | } from '@nextui-org/react' 17 | import { IconPlus, IconQrcode, IconTrash, IconUpload } from '@tabler/icons-react' 18 | import { QRCodeSVG } from 'qrcode.react' 19 | import { FC, Fragment, Key, useCallback, useMemo } from 'react' 20 | import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form' 21 | import { useTranslation } from 'react-i18next' 22 | import { z } from 'zod' 23 | import { useImportNodesMutation, useRemoveNodesMutation } from '~/apis/mutation' 24 | import { Node } from '~/app/(protected)/network/typings' 25 | import { Button } from '~/components/Button' 26 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal' 27 | import { nodeFormDefault, nodeFormSchema } from '~/schemas/node' 28 | 29 | const CheckNodeQRCodeButton: FC<{ name: string; link: string }> = ({ name, link }) => { 30 | const { 31 | isOpen: isCheckNodeQRCodeOpen, 32 | onOpenChange: onCheckNodeQRCodeOpenChange, 33 | onOpen: onCheckNodeQRCodeOpen 34 | } = useDisclosure() 35 | 36 | return ( 37 | 38 | 41 | 42 | 43 | 44 | {name} 45 | 46 | 47 | 48 | 49 | {link} 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | 58 | const RemoveNodeButton: FC<{ id: string; name: string }> = ({ id, name }) => { 59 | const { t } = useTranslation() 60 | 61 | const { 62 | isOpen: isRemoveOpen, 63 | onOpen: onRemoveOpen, 64 | onClose: onRemoveClose, 65 | onOpenChange: onRemoveOpenChange 66 | } = useDisclosure() 67 | 68 | const removeNodesMutation = useRemoveNodesMutation() 69 | 70 | return ( 71 | 72 | 75 | 76 | 77 | 78 | {t('primitives.remove', { resourceName: t('primitives.node') })} 79 | {name} 80 | 81 | { 85 | await removeNodesMutation.mutateAsync({ nodeIDs: [id] }) 86 | 87 | onRemoveClose() 88 | }} 89 | /> 90 | 91 | 92 | 93 | ) 94 | } 95 | 96 | const NodeTable: FC<{ 97 | nodes: Node[] 98 | isLoading?: boolean 99 | }> = ({ nodes, isLoading }) => { 100 | const { t } = useTranslation() 101 | 102 | const nodesTableColumns = useMemo( 103 | () => [ 104 | { key: 'name', name: t('primitives.name') }, 105 | { key: 'protocol', name: t('primitives.protocol') }, 106 | { key: 'address', name: t('primitives.address') }, 107 | { key: 'action', name: t('primitives.action') } 108 | ], 109 | [t] 110 | ) 111 | 112 | const renderCell = useCallback((item: Node, columnKey: Key) => { 113 | switch (columnKey) { 114 | case 'name': 115 | return item.tag || item.name 116 | 117 | case 'action': 118 | return ( 119 |
120 | 121 | 122 | 123 |
124 | ) 125 | 126 | default: 127 | return getKeyValue(item, columnKey) 128 | } 129 | }, []) 130 | 131 | return ( 132 | 133 | 134 | {(column) => {column.name}} 135 | 136 | 137 | 138 | {(item) => ( 139 | {(columnKey) => {renderCell(item, columnKey)}} 140 | )} 141 | 142 |
143 | ) 144 | } 145 | 146 | const ImportNodeInputList: FC<{ name: string }> = ({ name }) => { 147 | const { t } = useTranslation() 148 | const { fields, append, remove } = useFieldArray({ name }) 149 | 150 | return ( 151 |
152 | {fields.map((item, index) => ( 153 |
154 | ( 157 | 165 | )} 166 | /> 167 | 168 | ( 171 | 178 | )} 179 | /> 180 | 181 | 184 |
185 | ))} 186 | 187 |
188 | 191 |
192 |
193 | ) 194 | } 195 | 196 | export const NodeSection: FC<{ nodes: Node[]; isLoading?: boolean }> = ({ nodes, isLoading }) => { 197 | const { t } = useTranslation() 198 | const { 199 | isOpen: isImportNodeOpen, 200 | onOpen: onImportNodeOpen, 201 | onClose: onImportNodeClose, 202 | onOpenChange: onImportNodeOpenChange 203 | } = useDisclosure() 204 | 205 | const importNodeForm = useForm>({ 206 | shouldFocusError: true, 207 | resolver: zodResolver(nodeFormSchema), 208 | defaultValues: nodeFormDefault 209 | }) 210 | 211 | const importNodesMutation = useImportNodesMutation() 212 | 213 | return ( 214 |
215 |
216 |

{t('primitives.node')}

217 | 220 | 221 | 222 | 223 |
{ 225 | await importNodesMutation.mutateAsync(values.nodes) 226 | 227 | onImportNodeClose() 228 | })} 229 | > 230 | 231 | {t('primitives.node')} 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 |
240 |
241 |
242 |
243 | 244 | 245 |
246 | ) 247 | } 248 | -------------------------------------------------------------------------------- /src/app/(protected)/network/SubscriptionSection.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod' 2 | import { 3 | Accordion, 4 | AccordionItem, 5 | getKeyValue, 6 | Input, 7 | ModalBody, 8 | ModalContent, 9 | ModalHeader, 10 | Table, 11 | TableBody, 12 | TableCell, 13 | TableColumn, 14 | TableHeader, 15 | TableRow, 16 | useDisclosure 17 | } from '@nextui-org/react' 18 | import { IconPlus, IconRefresh, IconTrash, IconUpload } from '@tabler/icons-react' 19 | import dayjs from 'dayjs' 20 | import { FC, Fragment, useEffect, useMemo } from 'react' 21 | import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form' 22 | import { useTranslation } from 'react-i18next' 23 | import { z } from 'zod' 24 | import { 25 | useImportSubscriptionsMutation, 26 | useRemoveSubscriptionsMutation, 27 | useUpdateSubscriptionsMutation 28 | } from '~/apis/mutation' 29 | import { Button } from '~/components/Button' 30 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal' 31 | import { subscriptionFormDefault, subscriptionFormSchema } from '~/schemas/subscription' 32 | import { Node, Subscription } from './typings' 33 | 34 | const SubscriptionNodeTable: FC<{ 35 | nodes: Node[] 36 | isLoading?: boolean 37 | }> = ({ nodes, isLoading }) => { 38 | const { t } = useTranslation() 39 | 40 | const nodesTableColumns = useMemo( 41 | () => [ 42 | { key: 'name', name: t('primitives.name') }, 43 | { key: 'protocol', name: t('primitives.protocol') }, 44 | { key: 'address', name: t('primitives.address') } 45 | ], 46 | [t] 47 | ) 48 | 49 | return ( 50 | 51 | 52 | {(column) => {column.name}} 53 | 54 | 55 | 56 | {(item) => ( 57 | {(columnKey) => {getKeyValue(item, columnKey)}} 58 | )} 59 | 60 |
61 | ) 62 | } 63 | 64 | const ImportSubscriptionInputList: FC<{ name: string }> = ({ name }) => { 65 | const { t } = useTranslation() 66 | const { fields, append, remove } = useFieldArray({ name }) 67 | 68 | return ( 69 |
70 | {fields.map((item, index) => ( 71 |
72 | ( 75 | 83 | )} 84 | /> 85 | 86 | ( 89 | 96 | )} 97 | /> 98 | 99 | 102 |
103 | ))} 104 | 105 |
106 | 109 |
110 |
111 | ) 112 | } 113 | 114 | export const SubscriptionSection: FC<{ subscriptions: Subscription[] }> = ({ subscriptions }) => { 115 | const { t } = useTranslation() 116 | 117 | const { 118 | isOpen: isImportSubscriptionOpen, 119 | onOpen: onImportSubscriptionOpen, 120 | onClose: onImportSubscriptionClose, 121 | onOpenChange: onImportSubscriptionOpenChange 122 | } = useDisclosure() 123 | 124 | const importSubscriptionForm = useForm>({ 125 | shouldFocusError: true, 126 | resolver: zodResolver(subscriptionFormSchema), 127 | defaultValues: subscriptionFormDefault 128 | }) 129 | 130 | const { 131 | isOpen: isRemoveSubscriptionOpen, 132 | onOpen: onRemoveSubscriptionOpen, 133 | onClose: onRemoveSubscriptionClose, 134 | onOpenChange: onRemoveSubscriptionOpenChange 135 | } = useDisclosure() 136 | 137 | const importSubscriptionsMutation = useImportSubscriptionsMutation() 138 | const removeSubscriptionsMutation = useRemoveSubscriptionsMutation() 139 | const updateSubscriptionsMutation = useUpdateSubscriptionsMutation() 140 | 141 | useEffect(() => { 142 | const timer = setTimeout(() => { 143 | if (!isImportSubscriptionOpen) importSubscriptionForm.reset() 144 | }, 150) 145 | 146 | return () => timer && clearTimeout(timer) 147 | }, [importSubscriptionForm, isImportSubscriptionOpen]) 148 | 149 | return ( 150 |
151 |
152 |

{t('primitives.subscription')}

153 | 154 | 157 | 158 | 159 | 160 |
{ 162 | await importSubscriptionsMutation.mutateAsync(values.subscriptions) 163 | 164 | onImportSubscriptionClose() 165 | })} 166 | > 167 | 168 | {t('primitives.subscription')} 169 | 170 | 171 | 172 | 173 | 174 | 178 | 179 |
180 |
181 |
182 |
183 | 184 | 185 | {subscriptions.map((subscription) => ( 186 | 192 | 201 | 202 | 203 | 206 | 207 | 208 | 209 | 210 | {t('primitives.remove', { resourceName: t('primitives.subscription') })} 211 | 212 | {subscription.tag} 213 | 214 | { 218 | await removeSubscriptionsMutation.mutateAsync({ subscriptionIDs: [subscription.id] }) 219 | 220 | onRemoveSubscriptionClose() 221 | }} 222 | /> 223 | 224 | 225 | 226 |
227 | } 228 | > 229 | 230 | 231 | ))} 232 | 233 |
234 | ) 235 | } 236 | -------------------------------------------------------------------------------- /src/app/(protected)/rule/DNSSection.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { Input, ModalBody, ModalContent, ModalHeader, useDisclosure } from '@nextui-org/react' 5 | import { IconCode, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react' 6 | import { FC, Fragment, useEffect } from 'react' 7 | import { Controller, SubmitHandler, UseFormReturn, useForm } from 'react-hook-form' 8 | import { useTranslation } from 'react-i18next' 9 | import { z } from 'zod' 10 | import { 11 | useCreateDNSMutation, 12 | useRemoveDNSMutation, 13 | useRenameDNSMutation, 14 | useSelectDNSMutation, 15 | useUpdateDNSMutation 16 | } from '~/apis/mutation' 17 | import { useDNSsQuery, useGetJSONStorageRequest } from '~/apis/query' 18 | import { Button } from '~/components/Button' 19 | import { CodeBlock } from '~/components/CodeBlock' 20 | import { Editor } from '~/components/Editor' 21 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal' 22 | import { ResourceRadio, ResourceRadioGroup } from '~/components/ResourceRadioGroup' 23 | import { DNSFormDefault, DNSFormSchema } from '~/schemas/dns' 24 | 25 | type CreateOrEditModalContentProps = { 26 | isOpen: boolean 27 | onOpenChange: () => void 28 | form: UseFormReturn> 29 | onSubmit: SubmitHandler> 30 | } 31 | 32 | type CreateModalContentProps = { 33 | type: 'create' 34 | } 35 | 36 | type EditModalContentProps = { 37 | type: 'edit' 38 | name: string 39 | id: string 40 | } 41 | 42 | const CreateOrEditModal: FC = ({ 43 | isOpen, 44 | onOpenChange, 45 | ...createOrEditProps 46 | }) => { 47 | const { t } = useTranslation() 48 | const { type, form, onSubmit } = createOrEditProps 49 | const { 50 | register, 51 | reset, 52 | control, 53 | formState: { errors, dirtyFields, isSubmitting } 54 | } = form 55 | const dirty = Object.values(dirtyFields).some((dirty) => dirty) 56 | 57 | useEffect(() => { 58 | const timer = setTimeout(() => { 59 | if (!isOpen) reset() 60 | }, 150) 61 | 62 | return () => timer && clearTimeout(timer) 63 | }, [reset, isOpen]) 64 | 65 | return ( 66 | 67 | 68 |
69 | 70 | {type === 'edit' && {createOrEditProps.name}} 71 | 72 | {type === 'create' && t('primitives.create', { resourceName: t('primitives.dns') })} 73 | 74 | 75 | 76 | 83 | 84 | ( 88 | 89 | )} 90 | /> 91 | 92 | 93 | 99 | 100 |
101 |
102 | ) 103 | } 104 | 105 | type Details = { 106 | id: string 107 | name: string 108 | selected: boolean 109 | 110 | dns: { 111 | string: string 112 | } 113 | } 114 | 115 | const DetailsModal: FC<{ 116 | details: Details 117 | isOpen: boolean 118 | onOpenChange: () => void 119 | }> = ({ details, isOpen, onOpenChange }) => ( 120 | 121 | 122 | {details.id} 123 | 124 | 125 | {details.dns.string} 126 | 127 | 128 | 129 | ) 130 | 131 | const DetailsRadio: FC<{ 132 | details: Details 133 | isDefault?: boolean 134 | }> = ({ details, isDefault }) => { 135 | const { t } = useTranslation() 136 | const { isOpen: isDetailsOpen, onOpen: onDetailsOpen, onOpenChange: onDetailsOpenChange } = useDisclosure() 137 | const { 138 | isOpen: isEditOpen, 139 | onOpen: onEditOpen, 140 | onClose: onEditClose, 141 | onOpenChange: onEditOpenChange 142 | } = useDisclosure() 143 | const { 144 | isOpen: isRemoveOpen, 145 | onOpen: onRemoveOpen, 146 | onClose: onRemoveClose, 147 | onOpenChange: onRemoveOpenChange 148 | } = useDisclosure() 149 | const editForm = useForm>({ 150 | shouldFocusError: true, 151 | resolver: zodResolver(DNSFormSchema), 152 | defaultValues: DNSFormDefault 153 | }) 154 | 155 | const renameMutation = useRenameDNSMutation() 156 | const updateMutation = useUpdateDNSMutation() 157 | const removeMutation = useRemoveDNSMutation() 158 | 159 | const onEditPress = (name: string) => { 160 | editForm.reset({ 161 | name, 162 | text: details.dns.string 163 | }) 164 | onEditOpen() 165 | } 166 | 167 | const onEditSubmit: (id: string, name: string) => CreateOrEditModalContentProps['onSubmit'] = 168 | (id, name) => async (values) => { 169 | const { text } = values 170 | 171 | await updateMutation.mutateAsync({ 172 | id, 173 | dns: text 174 | }) 175 | 176 | if (values.name !== name) { 177 | await renameMutation.mutateAsync({ 178 | id, 179 | name: values.name 180 | }) 181 | } 182 | 183 | onEditClose() 184 | } 185 | 186 | return ( 187 | 192 | 195 | 196 | 197 | 198 |
199 | 202 | 203 | 212 | 213 | {!isDefault && ( 214 | 215 | 218 | 219 | 220 | 221 | {t('primitives.remove', { resourceName: t('primitives.dns') })} 222 | {details.name} 223 | 224 | { 228 | await removeMutation.mutateAsync({ id: details.id }) 229 | onRemoveClose() 230 | }} 231 | /> 232 | 233 | 234 | 235 | )} 236 |
237 | 238 | } 239 | > 240 | {details.name} 241 |
242 | ) 243 | } 244 | 245 | export const DNSSection = () => { 246 | const { t } = useTranslation() 247 | const createForm = useForm>({ 248 | shouldFocusError: true, 249 | resolver: zodResolver(DNSFormSchema), 250 | defaultValues: DNSFormDefault 251 | }) 252 | const defaultDNSIDQuery = useGetJSONStorageRequest(['defaultDNSID'] as const) 253 | const listQuery = useDNSsQuery() 254 | const { 255 | isOpen: isCreateOpen, 256 | onOpenChange: onCreateOpenChange, 257 | onOpen: onCreateOpen, 258 | onClose: onCreateClose 259 | } = useDisclosure() 260 | const isDefault = (id: string) => id === defaultDNSIDQuery.data?.defaultDNSID 261 | 262 | const createMutation = useCreateDNSMutation() 263 | const selectMutation = useSelectDNSMutation() 264 | 265 | const onCreateSubmit: CreateOrEditModalContentProps['onSubmit'] = async ({ name, text }) => { 266 | await createMutation.mutateAsync({ name, dns: text }) 267 | 268 | onCreateClose() 269 | } 270 | 271 | return ( 272 | selected)?.id || ''} 274 | onValueChange={async (id) => { 275 | await selectMutation.mutateAsync({ id }) 276 | }} 277 | label={ 278 |
279 |

{t('primitives.dns')}

280 | 281 | 282 | 285 | 286 | 293 | 294 |
295 | } 296 | > 297 | {listQuery.data?.dnss.map((details) => ( 298 | 299 | ))} 300 |
301 | ) 302 | } 303 | -------------------------------------------------------------------------------- /src/app/(protected)/rule/RoutingSection.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { Input, ModalBody, ModalContent, ModalHeader, useDisclosure } from '@nextui-org/react' 5 | import { IconCode, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react' 6 | import { FC, Fragment, useEffect } from 'react' 7 | import { Controller, SubmitHandler, UseFormReturn, useForm } from 'react-hook-form' 8 | import { useTranslation } from 'react-i18next' 9 | import { z } from 'zod' 10 | import { 11 | useCreateRoutingMutation, 12 | useRemoveRoutingMutation, 13 | useRenameRoutingMutation, 14 | useSelectRoutingMutation, 15 | useUpdateRoutingMutation 16 | } from '~/apis/mutation' 17 | import { useGetJSONStorageRequest, useRoutingsQuery } from '~/apis/query' 18 | import { Button } from '~/components/Button' 19 | import { CodeBlock } from '~/components/CodeBlock' 20 | import { Editor } from '~/components/Editor' 21 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal' 22 | import { ResourceRadio, ResourceRadioGroup } from '~/components/ResourceRadioGroup' 23 | import { routingFormDefault, routingFormSchema } from '~/schemas/routing' 24 | 25 | type CreateOrEditModalContentProps = { 26 | isOpen: boolean 27 | onOpenChange: () => void 28 | form: UseFormReturn> 29 | onSubmit: SubmitHandler> 30 | } 31 | 32 | type CreateModalContentProps = { 33 | type: 'create' 34 | } 35 | 36 | type EditModalContentProps = { 37 | type: 'edit' 38 | name: string 39 | id: string 40 | } 41 | 42 | const CreateOrEditModal: FC = ({ 43 | isOpen, 44 | onOpenChange, 45 | ...createOrEditProps 46 | }) => { 47 | const { t } = useTranslation() 48 | const { type, form, onSubmit } = createOrEditProps 49 | const { 50 | register, 51 | reset, 52 | control, 53 | formState: { errors, dirtyFields, isSubmitting } 54 | } = form 55 | const dirty = Object.values(dirtyFields).some((dirty) => dirty) 56 | 57 | useEffect(() => { 58 | const timer = setTimeout(() => { 59 | if (!isOpen) reset() 60 | }, 150) 61 | 62 | return () => timer && clearTimeout(timer) 63 | }, [reset, isOpen]) 64 | 65 | return ( 66 | 67 | 68 |
69 | 70 | {type === 'edit' && {createOrEditProps.name}} 71 | 72 | {type === 'create' && t('primitives.create', { resourceName: t('primitives.routing') })} 73 | 74 | 75 | 76 | 83 | 84 | ( 88 | 89 | )} 90 | /> 91 | 92 | 93 | 99 | 100 |
101 |
102 | ) 103 | } 104 | 105 | type Details = { 106 | id: string 107 | name: string 108 | selected: boolean 109 | 110 | routing: { 111 | string: string 112 | } 113 | } 114 | 115 | const DetailsModal: FC<{ 116 | details: Details 117 | isOpen: boolean 118 | onOpenChange: () => void 119 | }> = ({ details, isOpen, onOpenChange }) => ( 120 | 121 | 122 | {details.id} 123 | 124 | 125 | {details.routing.string} 126 | 127 | 128 | 129 | ) 130 | 131 | const DetailsRadio: FC<{ 132 | details: Details 133 | isDefault?: boolean 134 | }> = ({ details, isDefault }) => { 135 | const { t } = useTranslation() 136 | const { isOpen: isDetailsOpen, onOpen: onDetailsOpen, onOpenChange: onDetailsOpenChange } = useDisclosure() 137 | const { 138 | isOpen: isEditOpen, 139 | onOpen: onEditOpen, 140 | onClose: onEditClose, 141 | onOpenChange: onEditOpenChange 142 | } = useDisclosure() 143 | const { 144 | isOpen: isRemoveOpen, 145 | onOpen: onRemoveOpen, 146 | onClose: onRemoveClose, 147 | onOpenChange: onRemoveOpenChange 148 | } = useDisclosure() 149 | const editForm = useForm>({ 150 | shouldFocusError: true, 151 | resolver: zodResolver(routingFormSchema), 152 | defaultValues: routingFormDefault 153 | }) 154 | const renameMutation = useRenameRoutingMutation() 155 | const updateMutation = useUpdateRoutingMutation() 156 | const removeMutation = useRemoveRoutingMutation() 157 | 158 | const onEditPress = (name: string) => { 159 | editForm.reset({ 160 | name, 161 | text: details.routing.string 162 | }) 163 | onEditOpen() 164 | } 165 | 166 | const onEditSubmit: (id: string, name: string) => CreateOrEditModalContentProps['onSubmit'] = 167 | (id, name) => async (values) => { 168 | const { text } = values 169 | 170 | await updateMutation.mutateAsync({ 171 | id, 172 | routing: text 173 | }) 174 | 175 | if (values.name !== name) { 176 | await renameMutation.mutateAsync({ 177 | id, 178 | name: values.name 179 | }) 180 | } 181 | 182 | onEditClose() 183 | } 184 | 185 | return ( 186 | 191 | 194 | 195 | 196 | 197 |
198 | 201 | 202 | 211 | 212 | {!isDefault && ( 213 | 214 | 217 | 218 | 219 | 220 | {t('primitives.remove', { resourceName: t('primitives.routing') })} 221 | {details.name} 222 | 223 | { 227 | await removeMutation.mutateAsync({ id: details.id }) 228 | onRemoveClose() 229 | }} 230 | /> 231 | 232 | 233 | 234 | )} 235 |
236 | 237 | } 238 | > 239 | {details.name} 240 |
241 | ) 242 | } 243 | 244 | export const RoutingSection = () => { 245 | const { t } = useTranslation() 246 | const createForm = useForm>({ 247 | shouldFocusError: true, 248 | resolver: zodResolver(routingFormSchema), 249 | defaultValues: routingFormDefault 250 | }) 251 | const defaultRoutingIDQuery = useGetJSONStorageRequest(['defaultRoutingID'] as const) 252 | const listQuery = useRoutingsQuery() 253 | const { 254 | isOpen: isCreateOpen, 255 | onOpenChange: onCreateOpenChange, 256 | onOpen: onCreateOpen, 257 | onClose: onCreateClose 258 | } = useDisclosure() 259 | const isDefault = (id: string) => id === defaultRoutingIDQuery.data?.defaultRoutingID 260 | 261 | const createMutation = useCreateRoutingMutation() 262 | const selectMutation = useSelectRoutingMutation() 263 | 264 | const onCreateSubmit: CreateOrEditModalContentProps['onSubmit'] = async ({ name, text }) => { 265 | await createMutation.mutateAsync({ name, routing: text }) 266 | 267 | onCreateClose() 268 | } 269 | 270 | return ( 271 | selected)?.id || ''} 273 | onValueChange={async (id) => { 274 | await selectMutation.mutateAsync({ id }) 275 | }} 276 | label={ 277 |
278 |

{t('primitives.routing')}

279 | 280 | 281 | 284 | 285 | 292 | 293 |
294 | } 295 | > 296 | {listQuery.data?.routings.map((details) => ( 297 | 298 | ))} 299 |
300 | ) 301 | } 302 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { 5 | Avatar, 6 | ButtonGroup, 7 | Dropdown, 8 | DropdownItem, 9 | DropdownMenu, 10 | DropdownSection, 11 | DropdownTrigger, 12 | Input, 13 | Link, 14 | ModalBody, 15 | ModalContent, 16 | ModalHeader, 17 | Navbar, 18 | NavbarBrand, 19 | NavbarContent, 20 | NavbarItem, 21 | NavbarMenu, 22 | NavbarMenuItem, 23 | NavbarMenuToggle, 24 | Spinner, 25 | useDisclosure 26 | } from '@nextui-org/react' 27 | import { IconNetwork, IconNetworkOff, IconReload } from '@tabler/icons-react' 28 | import i18n from 'i18next' 29 | import ky from 'ky' 30 | import { useTheme } from 'next-themes' 31 | import NextLink from 'next/link' 32 | import { usePathname, useRouter } from 'next/navigation' 33 | import { FC, useEffect, useState } from 'react' 34 | import { Controller, FormProvider, useForm } from 'react-hook-form' 35 | import { useTranslation } from 'react-i18next' 36 | import { z } from 'zod' 37 | import { useRunMutation, useUpdateAvatarMutation, useUpdateNameMutation } from '~/apis/mutation' 38 | import { useGeneralQuery, useUserQuery } from '~/apis/query' 39 | import { Button } from '~/components/Button' 40 | import { LogoText } from '~/components/LogoText' 41 | import { Modal, ModalConfirmFormFooter, ModalSubmitFormFooter } from '~/components/Modal' 42 | import { updatePasswordFormDefault, useUpdatePasswordSchemaWithRefine } from '~/schemas/account' 43 | 44 | const AvatarUploader: FC<{ name: string }> = ({ name }) => { 45 | const [uploading, setUploading] = useState(false) 46 | 47 | return ( 48 | ( 51 |
52 |
53 | {uploading ? : } 54 |
55 | 56 | { 63 | setUploading(true) 64 | 65 | const file = e.target.files?.item(0) 66 | 67 | if (!file) return 68 | 69 | const formData = new FormData() 70 | formData.append('avatar', file) 71 | 72 | try { 73 | const { url } = await ky.post('/api/avatar', { body: formData }).json<{ url: string }>() 74 | 75 | field.onChange(url) 76 | } finally { 77 | setUploading(false) 78 | } 79 | }} 80 | /> 81 |
82 | )} 83 | /> 84 | ) 85 | } 86 | 87 | export const Header: FC = () => { 88 | const { t } = useTranslation() 89 | const { theme: curTheme, setTheme } = useTheme() 90 | const pathname = usePathname() 91 | const router = useRouter() 92 | 93 | const userQuery = useUserQuery() 94 | const generalQuery = useGeneralQuery() 95 | const runMutation = useRunMutation() 96 | 97 | const [isMenuOpen, setIsMenuOpen] = useState(false) 98 | 99 | const { 100 | isOpen: isUpdateProfileOpen, 101 | onOpen: onUpdateProfileOpen, 102 | onClose: onUpdateProfileClose, 103 | onOpenChange: onUpdateProfileOpenChange 104 | } = useDisclosure() 105 | 106 | const updateProfileSchema = z.object({ 107 | name: z.string().min(4).max(20), 108 | avatar: z.string().min(1) 109 | }) 110 | 111 | const updateProfileForm = useForm>({ 112 | resolver: zodResolver(updateProfileSchema), 113 | defaultValues: { name: '', avatar: '' } 114 | }) 115 | 116 | const updateProfileFormDirty = Object.values(updateProfileForm.formState.dirtyFields).some((dirty) => dirty) 117 | 118 | const updateNameMutation = useUpdateNameMutation() 119 | const updateAvatarMutation = useUpdateAvatarMutation() 120 | 121 | const { 122 | isOpen: isUpdatePasswordOpen, 123 | onOpen: onUpdatePasswordOpen, 124 | onClose: onUpdatePasswordClose, 125 | onOpenChange: onUpdatePasswordOpenChange 126 | } = useDisclosure() 127 | 128 | const updatePasswordSchemaWithRefine = useUpdatePasswordSchemaWithRefine()() 129 | 130 | const updatePasswordForm = useForm>({ 131 | resolver: zodResolver(updatePasswordSchemaWithRefine), 132 | defaultValues: updatePasswordFormDefault 133 | }) 134 | 135 | const navigationMenus = [ 136 | { name: t('primitives.network'), route: '/network' }, 137 | { name: t('primitives.rule'), route: '/rule' } 138 | ] 139 | 140 | useEffect(() => { 141 | const timer = setTimeout(() => { 142 | if (!isUpdatePasswordOpen) updatePasswordForm.reset() 143 | }, 150) 144 | 145 | return () => timer && clearTimeout(timer) 146 | }, [isUpdatePasswordOpen, updatePasswordForm]) 147 | 148 | return ( 149 | 150 | 151 | 152 | 153 | 154 | setIsMenuOpen(false)}> 155 | 156 | 157 | 158 | 159 | 160 | 161 | {navigationMenus.map((menu, index) => ( 162 | 163 | 164 | {menu.name} 165 | 166 | 167 | ))} 168 | 169 | 170 | 171 | {generalQuery.data?.general.dae.modified ? ( 172 | 180 | ) : ( 181 | 189 | )} 190 | 191 | 192 | 193 | 203 | 204 | 205 | { 209 | if (key === 'profile') { 210 | updateProfileForm.reset({ 211 | name: userQuery.data?.user.name || '', 212 | avatar: userQuery.data?.user.avatar || '' 213 | }) 214 | onUpdateProfileOpen() 215 | } 216 | if (key === 'update-password') onUpdatePasswordOpen() 217 | }} 218 | > 219 | 220 | 221 |

{t('primitives.username', { username: userQuery.data?.user.username })}

222 | 223 |

224 | {t('primitives.accountName', { accountName: userQuery.data?.user.name })} 225 |

226 |
227 | 228 | 229 | {t('actions.update', { 230 | resourceName: t('form.fields.password') 231 | })} 232 | 233 |
234 | 235 | 236 | 237 | 238 | {[ 239 | ['system', t('actions.systemMode')], 240 | ['dark', t('actions.darkMode')], 241 | ['light', t('actions.lightMode')] 242 | ].map(([theme, title]) => ( 243 | 250 | ))} 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | {[ 259 | ['en-US', t('primitives.english')], 260 | ['zh-Hans', t('primitives.chineseSimplified')] 261 | ].map(([language, title]) => ( 262 | 269 | ))} 270 | 271 | 272 | 273 | 274 | { 278 | await ky.post('/api/logout') 279 | 280 | router.replace('/login') 281 | }} 282 | > 283 | {t('actions.logout')} 284 | 285 |
286 |
287 |
288 | 289 | 290 | {navigationMenus.map((menu, index) => ( 291 | 292 | setIsMenuOpen(false)}> 293 | {menu.name} 294 | 295 | 296 | ))} 297 | 298 | 299 | 300 | 301 |
{ 303 | try { 304 | if (values.name !== userQuery.data?.user.name) { 305 | await updateNameMutation.mutateAsync(values.name) 306 | } 307 | 308 | if (values.avatar !== userQuery.data?.user.avatar) { 309 | await updateAvatarMutation.mutateAsync(values.avatar) 310 | } 311 | 312 | onUpdateProfileClose() 313 | } catch {} 314 | })} 315 | > 316 | 317 | {t('actions.update', { resourceName: t('primitives.profile') })} 318 | 319 | 320 |
321 | 327 | 328 | 329 |
330 |
331 | 332 | 338 |
339 |
340 |
341 |
342 | 343 | 344 |
{ 346 | try { 347 | await ky.post('/api/update-password', { 348 | json: { currentPassword: values.currentPassword, newPassword: values.newPassword } 349 | }) 350 | 351 | onUpdatePasswordClose() 352 | } catch {} 353 | })} 354 | > 355 | 356 | {t('actions.update', { resourceName: t('form.fields.password') })} 357 | 358 | 359 |
360 | 367 | 368 | 375 | 376 | 383 |
384 |
385 | 386 | 390 |
391 |
392 |
393 |
394 | ) 395 | } 396 | Header.displayName = 'Header' 397 | -------------------------------------------------------------------------------- /src/app/(protected)/network/GroupSection.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod' 4 | import { Divider } from '@nextui-org/divider' 5 | import { 6 | Accordion, 7 | AccordionItem, 8 | Card, 9 | CardBody, 10 | CardHeader, 11 | Chip, 12 | cn, 13 | Dropdown, 14 | DropdownItem, 15 | DropdownMenu, 16 | DropdownTrigger, 17 | Input, 18 | ModalBody, 19 | ModalContent, 20 | ModalFooter, 21 | ModalHeader, 22 | Select, 23 | SelectItem, 24 | useDisclosure 25 | } from '@nextui-org/react' 26 | import { IconPlus, IconTrash } from '@tabler/icons-react' 27 | import { differenceWith } from 'lodash' 28 | import { FC, Fragment, useMemo } from 'react' 29 | import { Controller, useForm } from 'react-hook-form' 30 | import { useTranslation } from 'react-i18next' 31 | import { z } from 'zod' 32 | import { Policy } from '~/apis/gql/graphql' 33 | import { 34 | useCreateGroupMutation, 35 | useGroupAddNodesMutation, 36 | useGroupAddSubscriptionsMutation, 37 | useGroupDelNodesMutation, 38 | useGroupDelSubscriptionsMutation, 39 | useGroupSetPolicyMutation, 40 | useRemoveGroupsMutation 41 | } from '~/apis/mutation' 42 | import { useGroupsQuery } from '~/apis/query' 43 | import { Group, Node, Subscription } from '~/app/(protected)/network/typings' 44 | import { Button } from '~/components/Button' 45 | import { Modal, ModalConfirmFormFooter } from '~/components/Modal' 46 | import { groupFormDefault, groupFormSchema } from '~/schemas/group' 47 | 48 | const GroupContent: FC<{ 49 | group: Group 50 | subscriptions: Subscription[] 51 | nodes: Node[] 52 | }> = ({ group, subscriptions, nodes }) => { 53 | const { t } = useTranslation() 54 | 55 | // subscriptions 56 | const allSubscriptions = useMemo(() => subscriptions.map(({ id }) => id), [subscriptions]) 57 | const selectedSubscriptions = useMemo( 58 | () => group.subscriptions.map((subscription) => subscription.id), 59 | [group.subscriptions] 60 | ) 61 | const groupAddSubscriptionsMutation = useGroupAddSubscriptionsMutation() 62 | const groupDelSubscriptionsMutation = useGroupDelSubscriptionsMutation() 63 | 64 | // nodes 65 | const allNodes = useMemo(() => nodes.map(({ id }) => id), [nodes]) 66 | const selectedNodes = useMemo(() => group.nodes.map((node) => node.id), [group.nodes]) 67 | const groupAddNodesMutation = useGroupAddNodesMutation() 68 | const groupDelNodesMutation = useGroupDelNodesMutation() 69 | 70 | return ( 71 | 72 | 77 | 145 | 146 | 147 | 152 | 210 | 211 | 212 | ) 213 | } 214 | 215 | const usePolicies = () => { 216 | const { t } = useTranslation() 217 | 218 | return [ 219 | { 220 | label: Policy.MinMovingAvg, 221 | value: Policy.MinMovingAvg, 222 | description: t('form.descriptions.group.MinMovingAvg') 223 | }, 224 | { 225 | label: Policy.MinAvg10, 226 | value: Policy.MinAvg10, 227 | description: t('form.descriptions.group.MinAvg10') 228 | }, 229 | { 230 | label: Policy.Min, 231 | value: Policy.Min, 232 | description: t('form.descriptions.group.Min') 233 | }, 234 | { 235 | label: Policy.Random, 236 | value: Policy.Random, 237 | description: t('form.descriptions.group.Random') 238 | } 239 | ] 240 | } 241 | 242 | const GroupCard: FC<{ 243 | group: Group 244 | subscriptions: Subscription[] 245 | nodes: Node[] 246 | }> = ({ group, subscriptions, nodes }) => { 247 | const { t } = useTranslation() 248 | 249 | const { 250 | isOpen: isRemoveGroupOpen, 251 | onOpen: onRemoveGroupOpen, 252 | onClose: onRemoveGroupClose, 253 | onOpenChange: onRemoveGroupOpenChange 254 | } = useDisclosure() 255 | 256 | const policies = usePolicies() 257 | 258 | const groupSetPolicyMutation = useGroupSetPolicyMutation() 259 | const removeGroupsMutation = useRemoveGroupsMutation() 260 | 261 | return ( 262 | 263 | 264 | {group.name} 265 | 266 |
267 | 268 | 269 | 272 | 273 | 274 | { 280 | if (selected === 'all') return 281 | 282 | await groupSetPolicyMutation.mutateAsync({ 283 | id: group.id, 284 | policy: Array.from(selected)[0] as Policy, 285 | policyParams: [] 286 | }) 287 | }} 288 | > 289 | {policies.map((policy) => ( 290 | 291 | {policy.label} 292 | 293 | ))} 294 | 295 | 296 | 297 | 300 | 301 | 302 | 303 | {t('primitives.remove', { resourceName: t('primitives.group') })} 304 | {group.name} 305 | 306 | { 310 | await removeGroupsMutation.mutateAsync({ 311 | groupIDs: [group.id] 312 | }) 313 | 314 | onRemoveGroupClose() 315 | }} 316 | /> 317 | 318 | 319 |
320 |
321 | 322 | 323 | 324 | 325 | 326 | 327 |
328 | ) 329 | } 330 | 331 | export const GroupSection: FC<{ nodes: Node[]; subscriptions: Subscription[] }> = ({ nodes, subscriptions }) => { 332 | const { t } = useTranslation() 333 | 334 | const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose, onOpenChange: onAddOpenChange } = useDisclosure() 335 | 336 | const groupsQuery = useGroupsQuery() 337 | 338 | const { 339 | register, 340 | control, 341 | handleSubmit, 342 | reset, 343 | formState: { errors, dirtyFields, isSubmitting } 344 | } = useForm>({ 345 | shouldFocusError: true, 346 | resolver: zodResolver(groupFormSchema), 347 | defaultValues: groupFormDefault 348 | }) 349 | const createGroupFormDirty = Object.values(dirtyFields).some((dirty) => dirty) 350 | 351 | const createGroupMutation = useCreateGroupMutation() 352 | 353 | const policies = usePolicies() 354 | 355 | return ( 356 |
357 |
358 |

{t('primitives.group')}

359 | 360 | 361 | 364 | 365 | 366 |
{ 368 | await createGroupMutation.mutateAsync({ 369 | name: values.name, 370 | policy: values.policy, 371 | policyParams: [] 372 | }) 373 | 374 | onAddClose() 375 | })} 376 | > 377 | 378 | {t('primitives.group')} 379 | 380 | 387 | 388 | ( 392 | 411 | )} 412 | /> 413 | 414 | 415 | 416 | 419 | 420 | 423 | 424 | 425 |
426 |
427 |
428 |
429 | 430 | {groupsQuery.data && ( 431 |
1 ? 'sm:grid-cols-2' : 'sm:grid-cols-1' 435 | )} 436 | > 437 | {groupsQuery.data?.groups.map((group) => ( 438 | 439 | ))} 440 |
441 | )} 442 |
443 | ) 444 | } 445 | -------------------------------------------------------------------------------- /src/apis/mutation.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query' 2 | import { graphql } from '~/apis/gql' 3 | import { GlobalInput, ImportArgument, Policy, PolicyParam } from '~/apis/gql/graphql' 4 | import { 5 | configsQueryKey, 6 | dnssQueryKey, 7 | generalQueryKey, 8 | groupsQueryKey, 9 | nodesQueryKey, 10 | routingsQueryKey, 11 | subscriptionsQueryKey, 12 | userQueryKey 13 | } from '~/apis/query' 14 | import { useGraphqlClient } from '~/contexts' 15 | 16 | export const setJsonStorageMutation = graphql(` 17 | mutation SetJsonStorage($paths: [String!]!, $values: [String!]!) { 18 | setJsonStorage(paths: $paths, values: $values) 19 | } 20 | `) 21 | 22 | export const createConfigMutation = graphql(` 23 | mutation CreateConfig($name: String, $global: globalInput) { 24 | createConfig(name: $name, global: $global) { 25 | id 26 | } 27 | } 28 | `) 29 | 30 | export const useCreateConfigMutation = () => { 31 | const gqlClient = useGraphqlClient() 32 | const queryClient = useQueryClient() 33 | 34 | return useMutation({ 35 | mutationFn: ({ name, global }: { name?: string; global?: GlobalInput }) => 36 | gqlClient.request(createConfigMutation, { 37 | name, 38 | global 39 | }), 40 | onSuccess: () => { 41 | void queryClient.invalidateQueries({ queryKey: configsQueryKey }) 42 | } 43 | }) 44 | } 45 | 46 | export const useUpdateConfigMutation = () => { 47 | const gqlClient = useGraphqlClient() 48 | const queryClient = useQueryClient() 49 | 50 | return useMutation({ 51 | mutationFn: ({ id, global }: { id: string; global: GlobalInput }) => { 52 | return gqlClient.request( 53 | graphql(` 54 | mutation UpdateConfig($id: ID!, $global: globalInput!) { 55 | updateConfig(id: $id, global: $global) { 56 | id 57 | } 58 | } 59 | `), 60 | { 61 | id, 62 | global 63 | } 64 | ) 65 | }, 66 | onSuccess: () => { 67 | void queryClient.invalidateQueries({ queryKey: configsQueryKey }) 68 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 69 | } 70 | }) 71 | } 72 | 73 | export const useRemoveConfigMutation = () => { 74 | const gqlClient = useGraphqlClient() 75 | const queryClient = useQueryClient() 76 | 77 | return useMutation({ 78 | mutationFn: ({ id }: { id: string }) => { 79 | return gqlClient.request( 80 | graphql(` 81 | mutation RemoveConfig($id: ID!) { 82 | removeConfig(id: $id) 83 | } 84 | `), 85 | { 86 | id 87 | } 88 | ) 89 | }, 90 | onSuccess: () => { 91 | void queryClient.invalidateQueries({ queryKey: configsQueryKey }) 92 | } 93 | }) 94 | } 95 | 96 | export const selectConfigMutation = graphql(` 97 | mutation SelectConfig($id: ID!) { 98 | selectConfig(id: $id) 99 | } 100 | `) 101 | 102 | export const useSelectConfigMutation = () => { 103 | const gqlClient = useGraphqlClient() 104 | const queryClient = useQueryClient() 105 | 106 | return useMutation({ 107 | mutationFn: ({ id }: { id: string }) => { 108 | return gqlClient.request(selectConfigMutation, { 109 | id 110 | }) 111 | }, 112 | onSuccess: () => { 113 | void queryClient.invalidateQueries({ queryKey: configsQueryKey }) 114 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 115 | } 116 | }) 117 | } 118 | 119 | export const useRenameConfigMutation = () => { 120 | const gqlClient = useGraphqlClient() 121 | const queryClient = useQueryClient() 122 | 123 | return useMutation({ 124 | mutationFn: ({ id, name }: { id: string; name: string }) => { 125 | return gqlClient.request( 126 | graphql(` 127 | mutation RenameConfig($id: ID!, $name: String!) { 128 | renameConfig(id: $id, name: $name) 129 | } 130 | `), 131 | { 132 | id, 133 | name 134 | } 135 | ) 136 | }, 137 | onSuccess: () => { 138 | void queryClient.invalidateQueries({ queryKey: configsQueryKey }) 139 | } 140 | }) 141 | } 142 | 143 | export const createRoutingMutation = graphql(` 144 | mutation CreateRouting($name: String, $routing: String) { 145 | createRouting(name: $name, routing: $routing) { 146 | id 147 | } 148 | } 149 | `) 150 | 151 | export const useCreateRoutingMutation = () => { 152 | const gqlClient = useGraphqlClient() 153 | const queryClient = useQueryClient() 154 | 155 | return useMutation({ 156 | mutationFn: ({ name, routing }: { name?: string; routing?: string }) => { 157 | return gqlClient.request(createRoutingMutation, { 158 | name, 159 | routing 160 | }) 161 | }, 162 | onSuccess: () => { 163 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey }) 164 | } 165 | }) 166 | } 167 | 168 | export const useUpdateRoutingMutation = () => { 169 | const gqlClient = useGraphqlClient() 170 | const queryClient = useQueryClient() 171 | 172 | return useMutation({ 173 | mutationFn: ({ id, routing }: { id: string; routing: string }) => { 174 | return gqlClient.request( 175 | graphql(` 176 | mutation UpdateRouting($id: ID!, $routing: String!) { 177 | updateRouting(id: $id, routing: $routing) { 178 | id 179 | } 180 | } 181 | `), 182 | { 183 | id, 184 | routing 185 | } 186 | ) 187 | }, 188 | onSuccess: () => { 189 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey }) 190 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 191 | } 192 | }) 193 | } 194 | 195 | export const useRemoveRoutingMutation = () => { 196 | const gqlClient = useGraphqlClient() 197 | const queryClient = useQueryClient() 198 | 199 | return useMutation({ 200 | mutationFn: ({ id }: { id: string }) => { 201 | return gqlClient.request( 202 | graphql(` 203 | mutation RemoveRouting($id: ID!) { 204 | removeRouting(id: $id) 205 | } 206 | `), 207 | { 208 | id 209 | } 210 | ) 211 | }, 212 | onSuccess: () => { 213 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey }) 214 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 215 | } 216 | }) 217 | } 218 | 219 | export const selectRoutingMutation = graphql(` 220 | mutation SelectRouting($id: ID!) { 221 | selectRouting(id: $id) 222 | } 223 | `) 224 | 225 | export const useSelectRoutingMutation = () => { 226 | const gqlClient = useGraphqlClient() 227 | const queryClient = useQueryClient() 228 | 229 | return useMutation({ 230 | mutationFn: ({ id }: { id: string }) => { 231 | return gqlClient.request(selectRoutingMutation, { 232 | id 233 | }) 234 | }, 235 | onSuccess: () => { 236 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey }) 237 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 238 | } 239 | }) 240 | } 241 | 242 | export const useRenameRoutingMutation = () => { 243 | const gqlClient = useGraphqlClient() 244 | const queryClient = useQueryClient() 245 | 246 | return useMutation({ 247 | mutationFn: ({ id, name }: { id: string; name: string }) => { 248 | return gqlClient.request( 249 | graphql(` 250 | mutation RenameRouting($id: ID!, $name: String!) { 251 | renameRouting(id: $id, name: $name) 252 | } 253 | `), 254 | { 255 | id, 256 | name 257 | } 258 | ) 259 | }, 260 | onSuccess: () => { 261 | void queryClient.invalidateQueries({ queryKey: routingsQueryKey }) 262 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 263 | } 264 | }) 265 | } 266 | 267 | export const createDNSMutation = graphql(` 268 | mutation CreateDNS($name: String, $dns: String) { 269 | createDns(name: $name, dns: $dns) { 270 | id 271 | } 272 | } 273 | `) 274 | 275 | export const useCreateDNSMutation = () => { 276 | const gqlClient = useGraphqlClient() 277 | const queryClient = useQueryClient() 278 | 279 | return useMutation({ 280 | mutationFn: ({ name, dns }: { name?: string; dns?: string }) => { 281 | return gqlClient.request(createDNSMutation, { 282 | name, 283 | dns 284 | }) 285 | }, 286 | onSuccess: () => { 287 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey }) 288 | } 289 | }) 290 | } 291 | 292 | export const useUpdateDNSMutation = () => { 293 | const gqlClient = useGraphqlClient() 294 | const queryClient = useQueryClient() 295 | 296 | return useMutation({ 297 | mutationFn: ({ id, dns }: { id: string; dns: string }) => { 298 | return gqlClient.request( 299 | graphql(` 300 | mutation UpdateDNS($id: ID!, $dns: String!) { 301 | updateDns(id: $id, dns: $dns) { 302 | id 303 | } 304 | } 305 | `), 306 | { 307 | id, 308 | dns 309 | } 310 | ) 311 | }, 312 | onSuccess: () => { 313 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey }) 314 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 315 | } 316 | }) 317 | } 318 | 319 | export const useRemoveDNSMutation = () => { 320 | const gqlClient = useGraphqlClient() 321 | const queryClient = useQueryClient() 322 | 323 | return useMutation({ 324 | mutationFn: ({ id }: { id: string }) => { 325 | return gqlClient.request( 326 | graphql(` 327 | mutation RemoveDNS($id: ID!) { 328 | removeDns(id: $id) 329 | } 330 | `), 331 | { 332 | id 333 | } 334 | ) 335 | }, 336 | onSuccess: () => { 337 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey }) 338 | } 339 | }) 340 | } 341 | 342 | export const selectDNSMutation = graphql(` 343 | mutation SelectDNS($id: ID!) { 344 | selectDns(id: $id) 345 | } 346 | `) 347 | 348 | export const useSelectDNSMutation = () => { 349 | const gqlClient = useGraphqlClient() 350 | const queryClient = useQueryClient() 351 | 352 | return useMutation({ 353 | mutationFn: ({ id }: { id: string }) => { 354 | return gqlClient.request(selectDNSMutation, { 355 | id 356 | }) 357 | }, 358 | onSuccess: () => { 359 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey }) 360 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 361 | } 362 | }) 363 | } 364 | 365 | export const useRenameDNSMutation = () => { 366 | const gqlClient = useGraphqlClient() 367 | const queryClient = useQueryClient() 368 | 369 | return useMutation({ 370 | mutationFn: ({ id, name }: { id: string; name: string }) => { 371 | return gqlClient.request( 372 | graphql(` 373 | mutation RenameDNS($id: ID!, $name: String!) { 374 | renameDns(id: $id, name: $name) 375 | } 376 | `), 377 | { 378 | id, 379 | name 380 | } 381 | ) 382 | }, 383 | onSuccess: () => { 384 | void queryClient.invalidateQueries({ queryKey: dnssQueryKey }) 385 | } 386 | }) 387 | } 388 | 389 | export const createGroupMutation = graphql(` 390 | mutation CreateGroup($name: String!, $policy: Policy!, $policyParams: [PolicyParam!]) { 391 | createGroup(name: $name, policy: $policy, policyParams: $policyParams) { 392 | id 393 | } 394 | } 395 | `) 396 | 397 | export const useCreateGroupMutation = () => { 398 | const gqlClient = useGraphqlClient() 399 | const queryClient = useQueryClient() 400 | 401 | return useMutation({ 402 | mutationFn: ({ name, policy, policyParams }: { name: string; policy: Policy; policyParams: PolicyParam[] }) => { 403 | return gqlClient.request(createGroupMutation, { 404 | name, 405 | policy, 406 | policyParams 407 | }) 408 | }, 409 | onSuccess: () => { 410 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 411 | } 412 | }) 413 | } 414 | 415 | export const useRemoveGroupsMutation = () => { 416 | const gqlClient = useGraphqlClient() 417 | const queryClient = useQueryClient() 418 | 419 | return useMutation({ 420 | mutationFn: ({ groupIDs }: { groupIDs: string[] }) => { 421 | return Promise.all( 422 | groupIDs.map((id) => 423 | gqlClient.request( 424 | graphql(` 425 | mutation RemoveGroup($id: ID!) { 426 | removeGroup(id: $id) 427 | } 428 | `), 429 | { 430 | id 431 | } 432 | ) 433 | ) 434 | ) 435 | }, 436 | onSuccess: () => { 437 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 438 | } 439 | }) 440 | } 441 | 442 | export const useGroupSetPolicyMutation = () => { 443 | const gqlClient = useGraphqlClient() 444 | const queryClient = useQueryClient() 445 | 446 | return useMutation({ 447 | mutationFn: ({ id, policy, policyParams }: { id: string; policy: Policy; policyParams: PolicyParam[] }) => { 448 | return gqlClient.request( 449 | graphql(` 450 | mutation GroupSetPolicy($id: ID!, $policy: Policy!, $policyParams: [PolicyParam!]) { 451 | groupSetPolicy(id: $id, policy: $policy, policyParams: $policyParams) 452 | } 453 | `), 454 | { 455 | id, 456 | policy, 457 | policyParams 458 | } 459 | ) 460 | }, 461 | onSuccess: () => { 462 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 463 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 464 | } 465 | }) 466 | } 467 | 468 | export const useRenameGroupMutation = () => { 469 | const gqlClient = useGraphqlClient() 470 | const queryClient = useQueryClient() 471 | 472 | return useMutation({ 473 | mutationFn: ({ id, name }: { id: string; name: string }) => { 474 | return gqlClient.request( 475 | graphql(` 476 | mutation RenameGroup($id: ID!, $name: String!) { 477 | renameGroup(id: $id, name: $name) 478 | } 479 | `), 480 | { 481 | id, 482 | name 483 | } 484 | ) 485 | }, 486 | onSuccess: () => { 487 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 488 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 489 | } 490 | }) 491 | } 492 | 493 | export const useGroupAddNodesMutation = () => { 494 | const gqlClient = useGraphqlClient() 495 | const queryClient = useQueryClient() 496 | 497 | return useMutation({ 498 | mutationFn: ({ id, nodeIDs }: { id: string; nodeIDs: string[] }) => { 499 | return gqlClient.request( 500 | graphql(` 501 | mutation GroupAddNodes($id: ID!, $nodeIDs: [ID!]!) { 502 | groupAddNodes(id: $id, nodeIDs: $nodeIDs) 503 | } 504 | `), 505 | { 506 | id, 507 | nodeIDs 508 | } 509 | ) 510 | }, 511 | onSuccess: () => { 512 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 513 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 514 | } 515 | }) 516 | } 517 | 518 | export const useGroupDelNodesMutation = () => { 519 | const gqlClient = useGraphqlClient() 520 | const queryClient = useQueryClient() 521 | 522 | return useMutation({ 523 | mutationFn: ({ id, nodeIDs }: { id: string; nodeIDs: string[] }) => { 524 | return gqlClient.request( 525 | graphql(` 526 | mutation GroupDelNodes($id: ID!, $nodeIDs: [ID!]!) { 527 | groupDelNodes(id: $id, nodeIDs: $nodeIDs) 528 | } 529 | `), 530 | { 531 | id, 532 | nodeIDs 533 | } 534 | ) 535 | }, 536 | onSuccess: () => { 537 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 538 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 539 | } 540 | }) 541 | } 542 | 543 | export const useGroupAddSubscriptionsMutation = () => { 544 | const gqlClient = useGraphqlClient() 545 | const queryClient = useQueryClient() 546 | 547 | return useMutation({ 548 | mutationFn: ({ id, subscriptionIDs }: { id: string; subscriptionIDs: string[] }) => { 549 | return gqlClient.request( 550 | graphql(` 551 | mutation GroupAddSubscriptions($id: ID!, $subscriptionIDs: [ID!]!) { 552 | groupAddSubscriptions(id: $id, subscriptionIDs: $subscriptionIDs) 553 | } 554 | `), 555 | { 556 | id, 557 | subscriptionIDs 558 | } 559 | ) 560 | }, 561 | onSuccess: () => { 562 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 563 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 564 | } 565 | }) 566 | } 567 | 568 | export const useGroupDelSubscriptionsMutation = () => { 569 | const gqlClient = useGraphqlClient() 570 | const queryClient = useQueryClient() 571 | 572 | return useMutation({ 573 | mutationFn: ({ id, subscriptionIDs }: { id: string; subscriptionIDs: string[] }) => { 574 | return gqlClient.request( 575 | graphql(` 576 | mutation GroupDelSubscriptions($id: ID!, $subscriptionIDs: [ID!]!) { 577 | groupDelSubscriptions(id: $id, subscriptionIDs: $subscriptionIDs) 578 | } 579 | `), 580 | { 581 | id, 582 | subscriptionIDs 583 | } 584 | ) 585 | }, 586 | onSuccess: () => { 587 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 588 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 589 | } 590 | }) 591 | } 592 | 593 | export const useImportNodesMutation = () => { 594 | const gqlClient = useGraphqlClient() 595 | const queryClient = useQueryClient() 596 | 597 | return useMutation({ 598 | mutationFn: (data: ImportArgument[]) => { 599 | return gqlClient.request( 600 | graphql(` 601 | mutation ImportNodes($rollbackError: Boolean!, $args: [ImportArgument!]!) { 602 | importNodes(rollbackError: $rollbackError, args: $args) { 603 | link 604 | error 605 | node { 606 | id 607 | } 608 | } 609 | } 610 | `), 611 | { 612 | rollbackError: false, 613 | args: data 614 | } 615 | ) 616 | }, 617 | onSuccess: () => { 618 | void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) 619 | } 620 | }) 621 | } 622 | 623 | export const useRemoveNodesMutation = () => { 624 | const gqlClient = useGraphqlClient() 625 | const queryClient = useQueryClient() 626 | 627 | return useMutation({ 628 | mutationFn: ({ nodeIDs }: { nodeIDs: string[] }) => { 629 | return gqlClient.request( 630 | graphql(` 631 | mutation RemoveNodes($ids: [ID!]!) { 632 | removeNodes(ids: $ids) 633 | } 634 | `), 635 | { 636 | ids: nodeIDs 637 | } 638 | ) 639 | }, 640 | onSuccess: () => { 641 | void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) 642 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 643 | void queryClient.invalidateQueries({ queryKey: configsQueryKey }) 644 | } 645 | }) 646 | } 647 | 648 | export const useImportSubscriptionsMutation = () => { 649 | const gqlClient = useGraphqlClient() 650 | const queryClient = useQueryClient() 651 | 652 | return useMutation({ 653 | mutationFn: (data: ImportArgument[]) => 654 | Promise.all( 655 | data.map((subscription) => 656 | gqlClient.request( 657 | graphql(` 658 | mutation ImportSubscription($rollbackError: Boolean!, $arg: ImportArgument!) { 659 | importSubscription(rollbackError: $rollbackError, arg: $arg) { 660 | link 661 | sub { 662 | id 663 | } 664 | nodeImportResult { 665 | node { 666 | id 667 | } 668 | } 669 | } 670 | } 671 | `), 672 | { 673 | rollbackError: false, 674 | arg: subscription 675 | } 676 | ) 677 | ) 678 | ), 679 | onSuccess: () => { 680 | void queryClient.invalidateQueries({ queryKey: subscriptionsQueryKey }) 681 | } 682 | }) 683 | } 684 | 685 | export const useUpdateSubscriptionsMutation = () => { 686 | const gqlClient = useGraphqlClient() 687 | const queryClient = useQueryClient() 688 | 689 | return useMutation({ 690 | mutationFn: ({ subscriptionIDs }: { subscriptionIDs: string[] }) => 691 | Promise.all( 692 | subscriptionIDs.map((id) => 693 | gqlClient.request( 694 | graphql(` 695 | mutation UpdateSubscription($id: ID!) { 696 | updateSubscription(id: $id) { 697 | id 698 | } 699 | } 700 | `), 701 | { 702 | id 703 | } 704 | ) 705 | ) 706 | ), 707 | onSuccess: () => { 708 | void queryClient.invalidateQueries({ queryKey: subscriptionsQueryKey }) 709 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 710 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 711 | } 712 | }) 713 | } 714 | 715 | export const useRemoveSubscriptionsMutation = () => { 716 | const gqlClient = useGraphqlClient() 717 | const queryClient = useQueryClient() 718 | 719 | return useMutation({ 720 | mutationFn: ({ subscriptionIDs }: { subscriptionIDs: string[] }) => 721 | gqlClient.request( 722 | graphql(` 723 | mutation RemoveSubscriptions($ids: [ID!]!) { 724 | removeSubscriptions(ids: $ids) 725 | } 726 | `), 727 | { 728 | ids: subscriptionIDs 729 | } 730 | ), 731 | onSuccess: () => { 732 | void queryClient.invalidateQueries({ queryKey: subscriptionsQueryKey }) 733 | void queryClient.invalidateQueries({ queryKey: groupsQueryKey }) 734 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 735 | } 736 | }) 737 | } 738 | 739 | export const useRunMutation = () => { 740 | const gqlClient = useGraphqlClient() 741 | const queryClient = useQueryClient() 742 | 743 | return useMutation({ 744 | mutationFn: (dry: boolean) => { 745 | return gqlClient.request( 746 | graphql(` 747 | mutation Run($dry: Boolean!) { 748 | run(dry: $dry) 749 | } 750 | `), 751 | { 752 | dry 753 | } 754 | ) 755 | }, 756 | onSuccess: () => { 757 | void queryClient.invalidateQueries({ queryKey: generalQueryKey }) 758 | } 759 | }) 760 | } 761 | 762 | export const useUpdateAvatarMutation = () => { 763 | const gqlClient = useGraphqlClient() 764 | const queryClient = useQueryClient() 765 | 766 | return useMutation({ 767 | mutationFn: (avatar: string) => { 768 | return gqlClient.request( 769 | graphql(` 770 | mutation UpdateAvatar($avatar: String) { 771 | updateAvatar(avatar: $avatar) 772 | } 773 | `), 774 | { 775 | avatar 776 | } 777 | ) 778 | }, 779 | onSuccess: () => { 780 | void queryClient.invalidateQueries({ queryKey: userQueryKey }) 781 | } 782 | }) 783 | } 784 | 785 | export const useUpdateNameMutation = () => { 786 | const gqlClient = useGraphqlClient() 787 | const queryClient = useQueryClient() 788 | 789 | return useMutation({ 790 | mutationFn: (name: string) => { 791 | return gqlClient.request( 792 | graphql(` 793 | mutation UpdateName($name: String) { 794 | updateName(name: $name) 795 | } 796 | `), 797 | { 798 | name 799 | } 800 | ) 801 | }, 802 | onSuccess: () => { 803 | void queryClient.invalidateQueries({ queryKey: userQueryKey }) 804 | } 805 | }) 806 | } 807 | 808 | export const updatePasswordMutation = graphql(` 809 | mutation UpdatePassword($currentPassword: String!, $newPassword: String!) { 810 | updatePassword(currentPassword: $currentPassword, newPassword: $newPassword) 811 | } 812 | `) 813 | 814 | export const useUpdatePasswordMutation = () => { 815 | const gqlClient = useGraphqlClient() 816 | 817 | return useMutation({ 818 | mutationFn: ({ currentPassword, newPassword }: { currentPassword: string; newPassword: string }) => { 819 | return gqlClient.request(updatePasswordMutation, { currentPassword, newPassword }) 820 | } 821 | }) 822 | } 823 | --------------------------------------------------------------------------------