├── .github
├── ISSUE_TEMPLATE
│ └── config.yml
└── workflows
│ ├── build-and-push-dev.yml
│ └── build-and-push.yml
├── .gitignore
├── .prettierrc
├── .vscode
├── extensions.json
└── settings.json
├── Dockerfile
├── LICENCE
├── Makefile
├── README.md
├── backend
├── .env.sample
├── .hygen.js
├── .npmrc
├── .prettierrc
├── docker-entrypoint.sh
├── ecosystem.config.js
├── eslint.config.mjs
├── libs
│ └── contracts
│ │ └── index.ts
├── nest-cli.json
├── package-lock.json
├── package.json
├── src
│ ├── app.module.ts
│ ├── common
│ │ ├── axios
│ │ │ ├── axios.module.ts
│ │ │ ├── axios.service.ts
│ │ │ └── index.ts
│ │ ├── config
│ │ │ ├── app-config
│ │ │ │ ├── config.schema.ts
│ │ │ │ └── index.ts
│ │ │ └── jwt
│ │ │ │ ├── index.ts
│ │ │ │ └── jwt.config.ts
│ │ ├── constants
│ │ │ ├── errors
│ │ │ │ ├── errors.ts
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── decorators
│ │ │ └── get-ip
│ │ │ │ ├── get-ip.ts
│ │ │ │ └── index.ts
│ │ ├── exception
│ │ │ ├── http-exeception-with-error-code.type.ts
│ │ │ ├── httpException.filter.ts
│ │ │ └── not-found-exception.filter.ts
│ │ ├── guards
│ │ │ └── worker-routes
│ │ │ │ ├── index.ts
│ │ │ │ └── worker-routes.guard.ts
│ │ ├── helpers
│ │ │ ├── can-parse-json.ts
│ │ │ ├── convert-bytes.ts
│ │ │ ├── error-handler-with-null.helper.ts
│ │ │ └── error-handler.helper.ts
│ │ ├── middlewares
│ │ │ ├── check-assets-cookie.middleware.ts
│ │ │ ├── get-real-ip.ts
│ │ │ ├── index.ts
│ │ │ ├── no-robots.middleware.ts
│ │ │ └── proxy-check.middleware.ts
│ │ ├── types
│ │ │ ├── command-response.type.ts
│ │ │ ├── converter.interface.ts
│ │ │ └── crud-port.ts
│ │ └── utils
│ │ │ ├── filter-logs
│ │ │ ├── filter-logs.ts
│ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── sanitize-username.ts
│ │ │ ├── sleep.ts
│ │ │ ├── startup-app
│ │ │ ├── get-start-message.ts
│ │ │ ├── index.ts
│ │ │ ├── init-log.util.ts
│ │ │ └── is-development.ts
│ │ │ └── validate-env-config.ts
│ ├── main.ts
│ └── modules
│ │ ├── root
│ │ ├── root.controller.ts
│ │ ├── root.module.ts
│ │ └── root.service.ts
│ │ └── subscription-page-backend.modules.ts
├── tsconfig.build.json
└── tsconfig.json
├── docker-compose-prod.yml
├── docker-compose.yml
├── frontend
├── .eslintrc.cjs
├── .npmrc
├── .prettierrc
├── .stylelintignore
├── .stylelintrc.json
├── @types
│ ├── i18n.d.ts
│ └── resources.ts
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── public
│ ├── assets
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── app-config.json
│ │ ├── apple-touch-icon.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ └── favicon.ico
│ └── locales
│ │ ├── en
│ │ └── main.json
│ │ ├── fa
│ │ └── main.json
│ │ ├── ru
│ │ └── main.json
│ │ └── zh
│ │ └── main.json
├── src
│ ├── app.tsx
│ ├── app
│ │ ├── i18n
│ │ │ └── i18n.ts
│ │ ├── layouts
│ │ │ └── root
│ │ │ │ ├── root.layout.tsx
│ │ │ │ └── root.module.css
│ │ └── router
│ │ │ ├── index.ts
│ │ │ └── router.tsx
│ ├── entities
│ │ └── subscription-info-store
│ │ │ ├── index.ts
│ │ │ ├── interfaces
│ │ │ ├── action.interface.ts
│ │ │ ├── index.ts
│ │ │ └── state.interface.ts
│ │ │ └── subscription-info-store.ts
│ ├── global.css
│ ├── main.tsx
│ ├── pages
│ │ ├── errors
│ │ │ └── 5xx-error
│ │ │ │ ├── ServerError.module.css
│ │ │ │ ├── index.ts
│ │ │ │ └── server-error.component.tsx
│ │ └── main
│ │ │ └── ui
│ │ │ ├── components
│ │ │ └── main.page.component.tsx
│ │ │ └── connectors
│ │ │ └── main.page.connector.tsx
│ ├── shared
│ │ ├── constants
│ │ │ ├── apps-config
│ │ │ │ ├── index.ts
│ │ │ │ └── interfaces
│ │ │ │ │ ├── app-list.interface.ts
│ │ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ └── theme
│ │ │ │ ├── index.ts
│ │ │ │ ├── overrides
│ │ │ │ ├── badge.ts
│ │ │ │ ├── breadcrumbs.tsx
│ │ │ │ ├── buttons.ts
│ │ │ │ ├── card
│ │ │ │ │ ├── card.module.css
│ │ │ │ │ └── index.ts
│ │ │ │ ├── drawer.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── inputs.ts
│ │ │ │ ├── layouts.ts
│ │ │ │ ├── loading-overlay.ts
│ │ │ │ ├── menu.ts
│ │ │ │ ├── notification.ts
│ │ │ │ ├── ring-progress.ts
│ │ │ │ ├── table.ts
│ │ │ │ └── tooltip.ts
│ │ │ │ └── theme.ts
│ │ ├── hocs
│ │ │ └── error-boundary
│ │ │ │ ├── error-boundary-hoc.tsx
│ │ │ │ └── index.ts
│ │ ├── ui
│ │ │ ├── index.ts
│ │ │ ├── info-block
│ │ │ │ ├── info-block.shared.tsx
│ │ │ │ └── interfaces
│ │ │ │ │ └── props.interface.tsx
│ │ │ ├── language-picker
│ │ │ │ ├── LanguagePicker.module.css
│ │ │ │ └── language-picker.shared.tsx
│ │ │ ├── loader-modal
│ │ │ │ ├── index.tsx
│ │ │ │ ├── interfaces
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── props.interface.ts
│ │ │ │ └── loader-model.shared.tsx
│ │ │ ├── loading-screen
│ │ │ │ ├── index.ts
│ │ │ │ └── loading-screen.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── page-header.tsx
│ │ │ ├── page
│ │ │ │ ├── index.ts
│ │ │ │ └── page.tsx
│ │ │ └── underline-shape.tsx
│ │ └── utils
│ │ │ ├── bytes
│ │ │ ├── bytes-to-gb
│ │ │ │ ├── bytes-to-gb.util.ts
│ │ │ │ └── index.ts
│ │ │ ├── gb-to-bytes
│ │ │ │ ├── gb-to-bytes.util.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ └── pretty-bytes
│ │ │ │ ├── index.ts
│ │ │ │ └── pretty-bytes.util.ts
│ │ │ ├── construct-subscription-url
│ │ │ ├── construct-subscription-url.tsx
│ │ │ └── index.ts
│ │ │ ├── fetch-with-progress
│ │ │ ├── fetch-with-progress.util.ts
│ │ │ └── index.ts
│ │ │ ├── migration.utils.ts
│ │ │ ├── misc
│ │ │ ├── boolean.ts
│ │ │ ├── date.ts
│ │ │ ├── factory.ts
│ │ │ ├── format.ts
│ │ │ ├── index.ts
│ │ │ ├── is.ts
│ │ │ ├── match.ts
│ │ │ ├── number.ts
│ │ │ └── text.ts
│ │ │ └── time-utils
│ │ │ ├── get-expiration-text
│ │ │ └── get-expiration-text.util.ts
│ │ │ ├── index.ts
│ │ │ └── s-to-ms
│ │ │ ├── index.ts
│ │ │ └── s-to-ms.util.ts
│ ├── vite-env.d.ts
│ └── widgets
│ │ └── main
│ │ ├── installation-guide
│ │ ├── installation-guide.base.widget.tsx
│ │ ├── installation-guide.widget.tsx
│ │ └── interfaces
│ │ │ └── platform-guide.props.interface.tsx
│ │ ├── subscription-info
│ │ └── subscription-info.widget.tsx
│ │ └── subscription-link
│ │ └── subscription-link.widget.tsx
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
└── public
└── assets
└── app-config.json
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | contact_links:
2 | - name: 🐞 Bug report
3 | url: https://github.com/remnawave/panel/issues/new/choose
4 | about: Please report any frontend/backend bugs on @remnawave/panel repository.
5 | - name: 💡 Feature request
6 | url: https://github.com/remnawave/panel/issues/new/choose
7 | about: Feel free to request any new features.
8 |
--------------------------------------------------------------------------------
/.github/workflows/build-and-push-dev.yml:
--------------------------------------------------------------------------------
1 | name: Build and push dev
2 |
3 | on:
4 | push:
5 | branches:
6 | - dev
7 |
8 | workflow_dispatch:
9 |
10 | jobs:
11 | send-start-deploy-telegram-message:
12 | name: Send Telegram message
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout source code
16 | uses: actions/checkout@v4
17 |
18 | - name: Send Telegram message
19 | uses: proDreams/actions-telegram-notifier@main
20 | with:
21 | token: ${{ secrets.TELEGRAM_TOKEN }}
22 | chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
23 | thread_id: ${{ secrets.TELEGRAM_TOPIC_ID }}
24 | status: info
25 | notify_fields: 'repository,branch,commit,workflow'
26 | title: 'Build started'
27 |
28 | build-docker-image:
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v3
33 |
34 | - name: Setup Node
35 | uses: actions/setup-node@v3
36 | with:
37 | node-version: '22.x'
38 |
39 | - name: Build frontend
40 | run: |
41 | cd frontend
42 | npm ci
43 | npm run start:build
44 |
45 | - name: Set up Docker Buildx
46 | uses: docker/setup-buildx-action@v2
47 |
48 | - name: Login to Docker Hub
49 | uses: docker/login-action@v2
50 | with:
51 | username: ${{ secrets.DOCKERHUB_USERNAME }}
52 | password: ${{ secrets.DOCKERHUB_TOKEN }}
53 |
54 | - name: Login to GitHub Container Registry
55 | uses: docker/login-action@v2
56 | with:
57 | registry: ghcr.io
58 | username: ${{ github.repository_owner }}
59 | password: ${{ secrets.TOKEN_GH_DEPLOY }}
60 |
61 | - name: Build and push
62 | uses: docker/build-push-action@v5
63 | with:
64 | context: .
65 | file: Dockerfile
66 | platforms: linux/amd64
67 | push: true
68 | tags: |
69 | remnawave/subscription-page:dev
70 | ghcr.io/remnawave/subscription-page:dev
71 |
72 | send-telegram-message:
73 | name: Send Telegram message
74 | needs: [build-docker-image]
75 | runs-on: ubuntu-latest
76 | steps:
77 | - name: Checkout source code
78 | uses: actions/checkout@v2
79 |
80 | - name: Send Telegram message
81 | uses: proDreams/actions-telegram-notifier@main
82 | with:
83 | token: ${{ secrets.TELEGRAM_TOKEN }}
84 | chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
85 | thread_id: ${{ secrets.TELEGRAM_TOPIC_ID }}
86 | status: success
87 | notify_fields: 'repository,branch,commit,workflow'
88 | title: 'Build finished'
89 |
90 | notify-on-error:
91 | runs-on: ubuntu-latest
92 | needs: [build-docker-image]
93 | if: failure()
94 | steps:
95 | - name: Checkout source code
96 | uses: actions/checkout@v2
97 |
98 | - name: Send Telegram message
99 | uses: proDreams/actions-telegram-notifier@main
100 | with:
101 | token: ${{ secrets.TELEGRAM_TOKEN }}
102 | chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
103 | thread_id: ${{ secrets.TELEGRAM_TOPIC_ID }}
104 | status: error
105 | notify_fields: 'repository,branch,commit,workflow'
106 | title: 'Build failed'
107 |
--------------------------------------------------------------------------------
/.github/workflows/build-and-push.yml:
--------------------------------------------------------------------------------
1 | name: Build and push
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | workflow_dispatch:
9 |
10 | jobs:
11 | send-start-deploy-telegram-message:
12 | name: Send Telegram message
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout source code
16 | uses: actions/checkout@v4
17 |
18 | - name: Send Telegram message
19 | uses: proDreams/actions-telegram-notifier@main
20 | with:
21 | token: ${{ secrets.TELEGRAM_TOKEN }}
22 | chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
23 | thread_id: ${{ secrets.TELEGRAM_TOPIC_ID }}
24 | status: info
25 | notify_fields: 'repository,branch,commit,workflow'
26 | title: 'Build started'
27 |
28 | build-docker-image:
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v3
33 |
34 | - name: Setup Node
35 | uses: actions/setup-node@v3
36 | with:
37 | node-version: '22.x'
38 |
39 | - name: Build frontend
40 | run: |
41 | cd frontend
42 | npm ci
43 | npm run start:build
44 |
45 | - name: Set up QEMU
46 | uses: docker/setup-qemu-action@v2
47 |
48 | - name: Set up Docker Buildx
49 | uses: docker/setup-buildx-action@v2
50 |
51 | - name: Login to Docker Hub
52 | uses: docker/login-action@v2
53 | with:
54 | username: ${{ secrets.DOCKERHUB_USERNAME }}
55 | password: ${{ secrets.DOCKERHUB_TOKEN }}
56 |
57 | - name: Login to GitHub Container Registry
58 | uses: docker/login-action@v2
59 | with:
60 | registry: ghcr.io
61 | username: ${{ github.repository_owner }}
62 | password: ${{ secrets.TOKEN_GH_DEPLOY }}
63 |
64 | - name: Build and push
65 | uses: docker/build-push-action@v5
66 | with:
67 | context: .
68 | file: Dockerfile
69 | platforms: linux/amd64, linux/arm64
70 | push: true
71 | tags: |
72 | remnawave/subscription-page:latest
73 | remnawave/subscription-page:${{github.ref_name}}
74 | ghcr.io/remnawave/subscription-page:latest
75 | ghcr.io/remnawave/subscription-page:${{github.ref_name}}
76 |
77 | send-telegram-message:
78 | name: Send Telegram message
79 | needs: [build-docker-image]
80 | runs-on: ubuntu-latest
81 | steps:
82 | - name: Checkout source code
83 | uses: actions/checkout@v2
84 |
85 | - name: Send Telegram message
86 | uses: proDreams/actions-telegram-notifier@main
87 | with:
88 | token: ${{ secrets.TELEGRAM_TOKEN }}
89 | chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
90 | thread_id: ${{ secrets.TELEGRAM_TOPIC_ID }}
91 | status: success
92 | notify_fields: 'repository,branch,commit,workflow'
93 | title: 'Build finished'
94 |
95 | notify-on-error:
96 | runs-on: ubuntu-latest
97 | needs: [build-docker-image]
98 | if: failure()
99 | steps:
100 | - name: Checkout source code
101 | uses: actions/checkout@v2
102 |
103 | - name: Send Telegram message
104 | uses: proDreams/actions-telegram-notifier@main
105 | with:
106 | token: ${{ secrets.TELEGRAM_TOKEN }}
107 | chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
108 | thread_id: ${{ secrets.TELEGRAM_TOPIC_ID }}
109 | status: error
110 | notify_fields: 'repository,branch,commit,workflow'
111 | title: 'Build failed'
112 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
132 | .DS_Store
133 | .vercel
134 | stats.html
135 | fsd-high-level-dependencies.html
136 |
137 | docker-compose-local.yml
138 |
139 | /frontend/node_modules
140 | /backend/dev_frontend
141 |
142 | /backend/.env
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": true,
3 | "tabWidth": 4,
4 | "printWidth": 100,
5 | "singleQuote": true,
6 | "trailingComma": "all",
7 | "overrides": [
8 | {
9 | "files": ["*.js", "*.jsx", "*.ts", "*.tsx"],
10 | "options": {
11 | "parser": "typescript"
12 | }
13 | },
14 | {
15 | "files": ["*.md", "*.json", "*.yaml", "*.yml"],
16 | "options": {
17 | "tabWidth": 2
18 | }
19 | }
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["vunguyentuan.vscode-postcss"]
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "frontend/node_modules/typescript/lib/",
3 | "cssVariables.lookupFiles": [
4 | "**/*.css",
5 | "**/*.scss",
6 | "**/*.sass",
7 | "**/*.less",
8 | "node_modules/@mantine/core/styles.css"
9 | ],
10 | "i18n-ally.localesPaths": [
11 | "frontend/public/locales",
12 | "frontend/src/app/i18n"
13 | ],
14 | "i18n-ally.displayLanguage": "en",
15 | "i18n-ally.refactor.templates": [
16 | {
17 | // affect scope (optional)
18 | // see https://github.com/lokalise/i18n-ally/blob/master/src/core/types.ts#L156-L156
19 | "source": "html-attribute",
20 | "templates": [
21 | "t('{key}'{args})",
22 | ],
23 | "include": [
24 | "src/**/*.{ts,tsx}",
25 | ],
26 | },
27 | {
28 | // affect scope (optional)
29 | // see https://github.com/lokalise/i18n-ally/blob/master/src/core/types.ts#L156-L156
30 | "source": "js-string",
31 | "templates": [
32 | "t('{key}'{args})",
33 | ],
34 | "include": [
35 | "src/**/*.{ts,tsx}",
36 | ],
37 | },
38 | {
39 | // affect scope (optional)
40 | // see https://github.com/lokalise/i18n-ally/blob/master/src/core/types.ts#L156-L156
41 | "source": "js-template",
42 | "templates": [
43 | "{t('{key}'{args})}",
44 | ],
45 | "include": [
46 | "src/**/*.{ts,tsx}",
47 | ],
48 | },
49 | {
50 | // affect scope (optional)
51 | // see https://github.com/lokalise/i18n-ally/blob/master/src/core/types.ts#L156-L156
52 | "source": "html-inline",
53 | "templates": [
54 | "{t('{key}'{args})}",
55 | ],
56 | "include": [
57 | "src/**/*.{ts,tsx}",
58 | ],
59 | },
60 | {
61 | // affect scope (optional)
62 | // see https://github.com/lokalise/i18n-ally/blob/master/src/core/types.ts#L156-L156
63 | "source": "jsx-text",
64 | "templates": [
65 | "{t('{key}'{args})}",
66 | ],
67 | "include": [
68 | "src/**/*.{ts,tsx}",
69 | ],
70 | },
71 | ],
72 | "i18n-ally.keystyle": "nested",
73 | "i18n-ally.indent": 4,
74 | "i18n-ally.extract.keygenStrategy": "slug",
75 | "i18n-ally.extract.keygenStyle": "default",
76 | "i18n-ally.extract.keyPrefix": "{fileNameWithoutExt}."
77 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22.18.0 AS backend-build
2 | WORKDIR /opt/app
3 |
4 | COPY backend/package*.json ./
5 | COPY backend/tsconfig.json ./
6 | COPY backend/tsconfig.build.json ./
7 |
8 | RUN npm ci
9 |
10 | COPY backend/ .
11 |
12 | RUN npm run build
13 |
14 | RUN npm cache clean --force
15 |
16 | RUN npm prune --omit=dev
17 |
18 | FROM node:22.18.0-alpine
19 | WORKDIR /opt/app
20 |
21 | COPY --from=backend-build /opt/app/dist ./dist
22 | COPY --from=backend-build /opt/app/node_modules ./node_modules
23 |
24 | COPY frontend/dist/ ./frontend/
25 |
26 | COPY backend/package*.json ./
27 |
28 |
29 | COPY backend/ecosystem.config.js ./
30 | COPY backend/docker-entrypoint.sh ./
31 |
32 | ENV PM2_DISABLE_VERSION_CHECK=true
33 |
34 | RUN npm install pm2 -g
35 |
36 | CMD [ "pm2-runtime", "start", "ecosystem.config.js", "--env", "production" ]
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for version bumping and dependency installation
2 |
3 | .PHONY: bump-patch bump-minor bump-major install help tag-release
4 |
5 | # Default target
6 | help:
7 | @echo "Available targets:"
8 | @echo " bump-patch - Bump patch version (x.x.X) for both backend and frontend"
9 | @echo " bump-minor - Bump minor version (x.X.x) for both backend and frontend"
10 | @echo " bump-major - Bump major version (X.x.x) for both backend and frontend"
11 | @echo " install - Run npm install in both backend and frontend directories"
12 | @echo " bump-and-install-patch - Bump patch version and install dependencies"
13 | @echo " bump-and-install-minor - Bump minor version and install dependencies"
14 | @echo " bump-and-install-major - Bump major version and install dependencies"
15 | @echo " tag-release - Create and push git tag for current version"
16 |
17 | # Bump patch version (x.x.X)
18 | bump-patch:
19 | @echo "Bumping patch version..."
20 | @cd backend && npm version patch --no-git-tag-version
21 | @cd frontend && npm version patch --no-git-tag-version
22 | @echo "✅ Patch version bumped successfully!"
23 |
24 | # Bump minor version (x.X.x)
25 | bump-minor:
26 | @echo "Bumping minor version..."
27 | @cd backend && npm version minor --no-git-tag-version
28 | @cd frontend && npm version minor --no-git-tag-version
29 | @echo "✅ Minor version bumped successfully!"
30 |
31 | # Bump major version (X.x.x)
32 | bump-major:
33 | @echo "Bumping major version..."
34 | @cd backend && npm version major --no-git-tag-version
35 | @cd frontend && npm version major --no-git-tag-version
36 | @echo "✅ Major version bumped successfully!"
37 |
38 | # Install dependencies
39 | install:
40 | @echo "Installing dependencies..."
41 | @echo "📦 Installing backend dependencies..."
42 | @cd backend && npm install
43 | @echo "📦 Installing frontend dependencies..."
44 | @cd frontend && npm install
45 | @echo "✅ Dependencies installed successfully!"
46 |
47 | # Combined targets
48 | bump-and-install-patch: bump-patch install
49 | @echo "🎉 Patch version bumped and dependencies installed!"
50 |
51 | bump-and-install-minor: bump-minor install
52 | @echo "🎉 Minor version bumped and dependencies installed!"
53 |
54 | bump-and-install-major: bump-major install
55 | @echo "🎉 Major version bumped and dependencies installed!"
56 |
57 | # Show current versions
58 | show-versions:
59 | @echo "Current versions:"
60 | @echo "Backend: $(shell cd backend && node -p "require('./package.json').version")"
61 | @echo "Frontend: $(shell cd frontend && node -p "require('./package.json').version")"
62 |
63 |
64 | tag-release:
65 | @VERSION=$$(cd backend && node -p "require('./package.json').version") && \
66 | echo "Creating signed tag for version $$VERSION..." && \
67 | git tag -s "$$VERSION" -m "Release $$VERSION" && \
68 | git push origin --follow-tags && \
69 | echo "Signed tag $$VERSION created and pushed"
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Remnawave Subscription Page
2 |
3 | Learn more about Remnawave [here](https://remna.st/).
4 |
5 | # Contributors
6 |
7 | Check [open issues](https://github.com/remnawave/subscription-page/issues) to help the progress of this project.
8 |
9 |
10 | Thanks to the all contributors who have helped improve Remnawave:
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/backend/.env.sample:
--------------------------------------------------------------------------------
1 | APP_PORT=3010
2 |
3 | ### Remnawave Panel URL, can be http://remnawave:3000 or https://panel.example.com
4 | REMNAWAVE_PANEL_URL=https://panel.example.com
5 |
6 |
7 | META_TITLE="Subscription page"
8 | META_DESCRIPTION="Subscription page description"
9 |
10 |
11 | # Serve at custom root path, for example, this value can be: CUSTOM_SUB_PREFIX=sub
12 | # Do not place / at the start/end
13 | CUSTOM_SUB_PREFIX=
14 |
15 |
16 |
17 | # Support Marzban links
18 | MARZBAN_LEGACY_LINK_ENABLED=false
19 | MARZBAN_LEGACY_SECRET_KEY=
20 | REMNAWAVE_API_TOKEN=
21 |
22 |
23 | # Don't use this unless you know what you are doing!
24 | # Example format: "2025-01-17T15:38:45.065Z"
25 | MARZBAN_LEGACY_SUBSCRIPTION_VALID_FROM=
26 |
27 |
28 |
29 |
30 |
31 | # If you use "Caddy with security" addon or "Tiny Auth", you can place here X-Api-Key, which will be applied to requests to Remnawave Panel.
32 | # Will be used with "X-Api-Key" header
33 | CADDY_AUTH_API_TOKEN=
34 |
35 |
36 | # If you use Cloudflare Zero Trust
37 | CLOUDFLARE_ZERO_TRUST_CLIENT_ID=""
38 | CLOUDFLARE_ZERO_TRUST_CLIENT_SECRET=""
39 |
--------------------------------------------------------------------------------
/backend/.hygen.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable prettier/prettier */
2 |
3 | module.exports = {
4 | templates: `${__dirname}/.hygen`,
5 | helpers: {
6 | /* ******* Helpers ******* */
7 | dasherize(str) {
8 | return this.inflection.dasherize(str).toLowerCase();
9 | },
10 | singularize(str) {
11 | // user -> users
12 | return this.inflection.singularize(str);
13 | },
14 | pluralize(str) {
15 | // user -> users
16 | return this.inflection.pluralize(str);
17 | },
18 | toDashCase(str) {
19 | // Convert any case to dash-case
20 | return str
21 | .replace(/([a-z])([A-Z])/g, '$1-$2')
22 | .replace(/_/g, '-')
23 | .toLowerCase();
24 | },
25 |
26 | pascalSingularize(str) {
27 | // users -> User
28 | return this.changeCase.pascal(this.singularize(str));
29 | },
30 | pascalPluralize(str) {
31 | // user -> Users
32 | return this.changeCase.pascal(this.pluralize(str));
33 | },
34 |
35 | /* ******* ******* ******* New `Module` ******* ******* ******* */
36 | /* ******* ******* ******* ******* ******* ******* ******* ******* */
37 |
38 | instanceName(name) {
39 | return this.changeCase.camel(this.EntityName(name));
40 | },
41 |
42 | /* ******* Module, controller, service, repository names ******* */
43 | ModuleName(name) {
44 | return `${this.pascalPluralize(name)}Module`;
45 | },
46 | ControllerName(name) {
47 | return `${this.pascalPluralize(name)}Controller`;
48 | },
49 | ServiceName(name) {
50 | return `${this.pascalPluralize(name)}Service`;
51 | },
52 | RepositoryName(name) {
53 | return `${this.pascalPluralize(name)}Repository`;
54 | },
55 |
56 | /* ******* Module, controller, service, repository folder/file names ******* */
57 | moduleFolderName(name) {
58 | return `${this.pluralize(this.toDashCase(name))}`;
59 | },
60 | moduleFileName(name) {
61 | return `${this.moduleFolderName(name)}.module`;
62 | },
63 | controllerFileName(name) {
64 | return `${this.moduleFolderName(name)}.controller`;
65 | },
66 | repositoryFileName(name) {
67 | return `${this.moduleFolderName(name)}.repository`;
68 | },
69 | serviceFileName(name) {
70 | return `${this.moduleFolderName(name)}.service`;
71 | },
72 |
73 | /* ******* Entity and table names ******* */
74 | entitiesFolderName() {
75 | return `entities`;
76 | },
77 |
78 | TableName(name) {
79 | name = name.replace(/-/g, '_');
80 |
81 | return this.inflection.pluralize(this.inflection.underscore(name).toLowerCase());
82 | },
83 | EntityName(name) {
84 | return this.pascalSingularize(name);
85 | },
86 |
87 | entityFileName(name) {
88 | return `${this.toDashCase(this.singularize(name))}.entity`;
89 | },
90 |
91 | /* ******* Dtos ******* */
92 | dtosFolderName() {
93 | return `dto`;
94 | },
95 |
96 | ResponseDtoName(name) {
97 | return `${this.pascalSingularize(name)}ResponseDto`;
98 | },
99 | CreateDtoName(name) {
100 | return `Create${this.pascalSingularize(name)}Dto`;
101 | },
102 | GetDtoName(name) {
103 | return `Get${this.pascalPluralize(name)}Dto`;
104 | },
105 | UpdateDtoName(name) {
106 | return `Update${this.pascalSingularize(name)}Dto`;
107 | },
108 |
109 | responseDtoFileName(name) {
110 | return `${this.singularize(name)}-response.dto`;
111 | },
112 | createDtoFileName(name) {
113 | return `create-${this.singularize(name)}.dto`;
114 | },
115 | getDtoFileName(name) {
116 | return `get-${this.pluralize(name)}.dto`;
117 | },
118 | updateDtoFileName(name) {
119 | return `update-${this.singularize(name)}.dto`;
120 | },
121 |
122 | /* ******* ******* ******* New `Queue` ******* ******* ******* */
123 | /* ******* ******* ******* ******* ******* ******* ******* ******* */
124 |
125 | QueueNameEnumKey(queueName) {
126 | return this.changeCase.camel(queueName);
127 | },
128 |
129 | queueJobNamesEnumFileName(queueName) {
130 | return `${this.queueFolderName(queueName)}-job-names.enum`;
131 | },
132 | QueueJobNamesEnumName(queueName) {
133 | return `${this.changeCase.pascal(queueName)}JobNames`;
134 | },
135 |
136 | queueParamName(queueName) {
137 | return `${this.changeCase.camel(queueName)}Queue`;
138 | },
139 |
140 | /* ******* Queue module, processor, service names ******* */
141 | QueueModuleName(queueName) {
142 | return `${this.changeCase.pascal(queueName)}QueueModule`;
143 | },
144 | QueueProcessorName(queueName) {
145 | return `${this.changeCase.pascal(queueName)}QueueProcessor`;
146 | },
147 | QueueServiceName(queueName) {
148 | return `${this.changeCase.pascal(queueName)}QueueService`;
149 | },
150 |
151 | /* ******* Queue module, processor, service folder/file names ******* */
152 | queueFolderName(queueName) {
153 | return this.toDashCase(queueName);
154 | },
155 |
156 | queueModuleFileName(queueName) {
157 | return `${this.queueFolderName(queueName)}.module`;
158 | },
159 | queueProcessorFileName(queueName) {
160 | return `${this.queueFolderName(queueName)}.processor`;
161 | },
162 | queueServiceFileName(queueName) {
163 | return `${this.queueFolderName(queueName)}.service`;
164 | },
165 | },
166 | };
167 |
--------------------------------------------------------------------------------
/backend/.npmrc:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": true,
3 | "tabWidth": 4,
4 | "printWidth": 100,
5 | "singleQuote": true,
6 | "trailingComma": "all",
7 | "overrides": [
8 | {
9 | "files": [
10 | "*.js",
11 | "*.jsx",
12 | "*.ts",
13 | "*.tsx"
14 | ],
15 | "options": {
16 | "parser": "typescript"
17 | }
18 | },
19 | {
20 | "files": [
21 | "*.md",
22 | "*.json",
23 | "*.yaml",
24 | "*.yml"
25 | ],
26 | "options": {
27 | "tabWidth": 2
28 | }
29 | }
30 | ]
31 | }
--------------------------------------------------------------------------------
/backend/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo "Starting entrypoint script..."
4 |
5 | echo "Starting pm2..."
6 | pm2-runtime start ecosystem.config.js --env production
7 |
8 | echo "Entrypoint script completed."
9 |
--------------------------------------------------------------------------------
/backend/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | name: 'remnawave-subscription-page',
5 | script: 'dist/src/main.js',
6 | watch: false,
7 | instances: process.env.SUBSCRIPTION_PAGE_INSTANCES || 1,
8 | merge_logs: true,
9 | exec_mode: 'cluster',
10 | instance_var: 'INSTANCE_ID',
11 | env_development: {
12 | NODE_ENV: 'development',
13 | },
14 | env_production: {
15 | NODE_ENV: 'production',
16 | },
17 | namespace: 'subscription-page',
18 | },
19 | ],
20 | };
21 |
--------------------------------------------------------------------------------
/backend/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin';
2 | import perfectionist from 'eslint-plugin-perfectionist';
3 | import tsParser from '@typescript-eslint/parser';
4 | import { FlatCompat } from '@eslint/eslintrc';
5 | import { fileURLToPath } from 'node:url';
6 | import paths from 'eslint-plugin-paths';
7 | import globals from 'globals';
8 | import path from 'node:path';
9 |
10 | const __filename = fileURLToPath(import.meta.url);
11 | const __dirname = path.dirname(__filename);
12 | const compat = new FlatCompat({
13 | baseDirectory: __dirname,
14 | });
15 |
16 | export default [
17 | {
18 | ignores: ['**/.eslintrc.js', 'prisma/**/*', '.hygen.js', '.hygen/**/*'],
19 | },
20 | ...compat.extends('plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'),
21 | perfectionist.configs['recommended-alphabetical'],
22 | {
23 | plugins: {
24 | '@typescript-eslint': typescriptEslintEslintPlugin,
25 | paths,
26 | },
27 |
28 | languageOptions: {
29 | globals: {
30 | ...globals.node,
31 | },
32 |
33 | parser: tsParser,
34 | ecmaVersion: 'latest',
35 | sourceType: 'commonjs',
36 |
37 | parserOptions: {
38 | project: 'tsconfig.json',
39 | tsconfigRootDir: __dirname,
40 | },
41 | },
42 |
43 | rules: {
44 | 'perfectionist/sort-imports': [
45 | 'error',
46 | {
47 | type: 'line-length',
48 | order: 'desc',
49 | ignoreCase: true,
50 | specialCharacters: 'keep',
51 | internalPattern: ['^~/.+'],
52 | tsconfigRootDir: '.',
53 | partitionByComment: false,
54 | partitionByNewLine: false,
55 | newlinesBetween: 'always',
56 | maxLineLength: undefined,
57 | tsconfigRootDir: __dirname,
58 |
59 | groups: [
60 | 'type',
61 | ['builtin', 'external'],
62 | 'internal-type',
63 | 'internal',
64 | 'nestJs',
65 | 'remnawave',
66 | 'aliasCommon',
67 | { newlinesBetween: 'never' },
68 | 'aliasLibs',
69 | 'aliasIntegrationModules',
70 | 'aliasModules',
71 | 'aliasScheduler',
72 | 'aliasQueue',
73 | ['parent-type', 'sibling-type', 'index-type'],
74 | ['parent', 'sibling', 'index'],
75 | 'object',
76 | 'unknown',
77 | ],
78 |
79 | customGroups: {
80 | value: {
81 | aliasModules: '@modules/*.',
82 | aliasCommon: '@common/*.',
83 | aliasLibs: '@libs/*.',
84 | aliasIntegrationModules: '@integration-modules/*.',
85 | aliasScheduler: '@scheduler/*.',
86 | aliasQueue: '@queue/*.',
87 | remnawave: '@remnawave/*.',
88 | nestJs: '@nestjs/*.',
89 | },
90 | },
91 |
92 | environment: 'node',
93 | },
94 | ],
95 | 'perfectionist/sort-decorators': [
96 | 'error',
97 | {
98 | groups: ['unknown', 'httpCodes', 'filters', 'controllers', 'nestJSMethods'],
99 |
100 | customGroups: {
101 | httpCodes: ['HttpCode'],
102 | filters: ['UseFilters'],
103 | controllers: ['Controller'],
104 | nestJSMethods: ['Post', 'Get', 'Put', 'Delete', 'Patch', 'Options', 'Head'],
105 | },
106 | },
107 | ],
108 |
109 | 'perfectionist/sort-objects': ['off'],
110 | 'perfectionist/sort-classes': ['off'],
111 | 'perfectionist/sort-switch-case': ['off'],
112 | 'perfectionist/sort-object-types': ['off'],
113 | 'perfectionist/sort-interfaces': ['off'],
114 | 'perfectionist/sort-union-types': ['off'],
115 | 'perfectionist/sort-named-imports': ['off'],
116 | 'perfectionist/sort-modules': ['off'],
117 | 'paths/alias': 'error',
118 | '@typescript-eslint/interface-name-prefix': 'off',
119 | '@typescript-eslint/explicit-function-return-type': 'off',
120 | '@typescript-eslint/explicit-module-boundary-types': 'off',
121 | '@typescript-eslint/no-explicit-any': 'off',
122 | '@typescript-eslint/no-namespace': 'off',
123 | 'linebreak-style': 0,
124 | 'no-console': 'warn',
125 |
126 | 'prettier/prettier': [
127 | 'error',
128 | {
129 | bracketSpacing: true,
130 | tabWidth: 4,
131 | printWidth: 100,
132 | singleQuote: true,
133 | trailingComma: 'all',
134 |
135 | overrides: [
136 | {
137 | files: ['*.js', '*.jsx', '*.ts', '*.tsx'],
138 |
139 | options: {
140 | parser: 'typescript',
141 | },
142 | },
143 | {
144 | files: ['*.md', '*.json', '*.yaml', '*.yml'],
145 |
146 | options: {
147 | tabWidth: 2,
148 | },
149 | },
150 | ],
151 | },
152 | ],
153 | },
154 | },
155 | ];
156 |
--------------------------------------------------------------------------------
/backend/libs/contracts/index.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remnawave/subscription-page/b212ae7d430e749fc7839c780bd9fc1b5c4e0bec/backend/libs/contracts/index.ts
--------------------------------------------------------------------------------
/backend/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true,
7 | "tsConfigPath": "tsconfig.json"
8 | },
9 | "projects": {
10 | "app": {
11 | "type": "application",
12 | "sourceRoot": "src",
13 | "entryFile": "main"
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@remnawave/subscription-page",
3 | "version": "6.0.8",
4 | "description": "Remnawave Subscription Page",
5 | "private": false,
6 | "type": "commonjs",
7 | "license": "AGPL-3.0-only",
8 | "author": "REMNAWAVE ",
9 | "homepage": "https://github.com/remnawave",
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/remnawave/subscription-page"
13 | },
14 | "bugs": {
15 | "url": "https://github.com/remnawave/subscription-page/issues"
16 | },
17 | "scripts": {
18 | "build": "nest build",
19 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
20 | "start": "nest start",
21 | "start:dev": "NODE_ENV=development nest start --watch",
22 | "start:debug": "NODE_ENV=development nest start --debug --watch",
23 | "start:prod": "NODE_ENV=production NODE_NO_WARNINGS=1 node dist/src/main",
24 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
25 | "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
26 | "lint:fix:imports": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix --rule 'perfectionist/sort-imports: error'",
27 | "prepare": "husky"
28 | },
29 | "dependencies": {
30 | "@kastov/request-ip": "^0.0.5",
31 | "@ladjs/consolidate": "^1.0.4",
32 | "@nestjs/axios": "4.0.1",
33 | "@nestjs/common": "11.1.6",
34 | "@nestjs/config": "4.0.2",
35 | "@nestjs/core": "11.1.6",
36 | "@nestjs/jwt": "^11.0.0",
37 | "@nestjs/platform-express": "11.1.6",
38 | "@nestjs/serve-static": "5.0.3",
39 | "@remnawave/backend-contract": "2.1.42",
40 | "axios": "^1.12.2",
41 | "class-transformer": "^0.5.1",
42 | "compression": "^1.8.1",
43 | "consola": "^3.4.2",
44 | "cookie-parser": "^1.4.7",
45 | "dayjs": "^1.11.18",
46 | "ejs": "^3.1.10",
47 | "enhanced-ms": "^4.1.0",
48 | "grammy": "^1.36.1",
49 | "helmet": "^8.1.0",
50 | "husky": "9.1.7",
51 | "hygen": "^6.2.11",
52 | "jsonwebtoken": "^9.0.2",
53 | "morgan": "^1.10.1",
54 | "nanoid": "5.1.5",
55 | "nest-winston": "^1.10.2",
56 | "nestjs-zod": "4.3.1",
57 | "pkg-types": "2.1.0",
58 | "pm2": "6.0.11",
59 | "reflect-metadata": "0.2.2",
60 | "rxjs": "7.8.2",
61 | "semver": "^7.7.2",
62 | "superjson": "2.2.2",
63 | "table": "^6.9.0",
64 | "winston": "^3.17.0",
65 | "xbytes": "^1.9.1",
66 | "yaml": "^2.7.1",
67 | "zod": "^3.24.4"
68 | },
69 | "devDependencies": {
70 | "@nestjs/cli": "11.0.7",
71 | "@nestjs/schematics": "11.0.5",
72 | "@types/compression": "^1.7.5",
73 | "@types/cookie-parser": "^1.4.8",
74 | "@types/cors": "^2.8.17",
75 | "@types/express": "^5.0.1",
76 | "@types/js-yaml": "^4.0.9",
77 | "@types/jsonwebtoken": "^9.0.9",
78 | "@types/lodash": "^4.17.16",
79 | "@types/morgan": "^1.9.9",
80 | "@types/node": "^22.15.14",
81 | "@types/passport-http": "^0.3.11",
82 | "@types/semver": "^7.7.0",
83 | "@typescript-eslint/eslint-plugin": "^8.32.0",
84 | "@typescript-eslint/parser": "^8.32.0",
85 | "eslint": "9.26.0",
86 | "eslint-config-prettier": "^10.1.2",
87 | "eslint-plugin-paths": "^1.1.0",
88 | "eslint-plugin-perfectionist": "^4.12.3",
89 | "eslint-plugin-prettier": "^5.4.0",
90 | "globals": "^16.0.0",
91 | "prettier": "^3.5.3",
92 | "source-map-support": "^0.5.21",
93 | "ts-loader": "9.5.2",
94 | "ts-node": "10.9.2",
95 | "tsconfig-paths": "^4.2.0",
96 | "typescript": "~5.8.3",
97 | "typescript-eslint": "^8.32.0"
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/backend/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { ConfigModule } from '@nestjs/config';
2 | import { Module } from '@nestjs/common';
3 |
4 | import { validateEnvConfig } from '@common/utils/validate-env-config';
5 | import { configSchema, Env } from '@common/config/app-config';
6 | import { AxiosModule } from '@common/axios/axios.module';
7 |
8 | import { SubscriptionPageBackendModule } from '@modules/subscription-page-backend.modules';
9 |
10 | @Module({
11 | imports: [
12 | AxiosModule,
13 | ConfigModule.forRoot({
14 | isGlobal: true,
15 | envFilePath: '.env',
16 | validate: (config) => validateEnvConfig(configSchema, config),
17 | }),
18 |
19 | SubscriptionPageBackendModule,
20 | ],
21 | })
22 | export class AppModule {}
23 |
--------------------------------------------------------------------------------
/backend/src/common/axios/axios.module.ts:
--------------------------------------------------------------------------------
1 | import { Global, Module } from '@nestjs/common';
2 |
3 | import { AxiosService } from './axios.service';
4 |
5 | @Global()
6 | @Module({
7 | imports: [],
8 | providers: [AxiosService],
9 | exports: [AxiosService],
10 | })
11 | export class AxiosModule {}
12 |
--------------------------------------------------------------------------------
/backend/src/common/axios/axios.service.ts:
--------------------------------------------------------------------------------
1 | import axios, {
2 | AxiosError,
3 | AxiosInstance,
4 | AxiosResponseHeaders,
5 | RawAxiosResponseHeaders,
6 | } from 'axios';
7 |
8 | import { Injectable, Logger } from '@nestjs/common';
9 | import { ConfigService } from '@nestjs/config';
10 |
11 | import {
12 | GetSubscriptionInfoByShortUuidCommand,
13 | GetUserByUsernameCommand,
14 | REMNAWAVE_REAL_IP_HEADER,
15 | TRequestTemplateTypeKeys,
16 | } from '@remnawave/backend-contract';
17 |
18 | import { ICommandResponse } from '../types/command-response.type';
19 |
20 | @Injectable()
21 | export class AxiosService {
22 | public axiosInstance: AxiosInstance;
23 | private readonly logger = new Logger(AxiosService.name);
24 |
25 | constructor(private readonly configService: ConfigService) {
26 | this.axiosInstance = axios.create({
27 | baseURL: this.configService.getOrThrow('REMNAWAVE_PANEL_URL'),
28 | timeout: 45_000,
29 | headers: {
30 | 'x-forwarded-for': '127.0.0.1',
31 | 'x-forwarded-proto': 'https',
32 | 'user-agent': 'Remnawave Subscription Page',
33 | Authorization: `Bearer ${this.configService.get('REMNAWAVE_API_TOKEN')}`,
34 | },
35 | });
36 |
37 | const caddyAuthApiToken = this.configService.get(
38 | 'CADDY_AUTH_API_TOKEN',
39 | );
40 |
41 | const cloudflareZeroTrustClientId = this.configService.get(
42 | 'CLOUDFLARE_ZERO_TRUST_CLIENT_ID',
43 | );
44 | const cloudflareZeroTrustClientSecret = this.configService.get(
45 | 'CLOUDFLARE_ZERO_TRUST_CLIENT_SECRET',
46 | );
47 |
48 | if (caddyAuthApiToken) {
49 | this.axiosInstance.defaults.headers.common['X-Api-Key'] = caddyAuthApiToken;
50 | }
51 |
52 | if (cloudflareZeroTrustClientId && cloudflareZeroTrustClientSecret) {
53 | this.axiosInstance.defaults.headers.common['CF-Access-Client-Id'] =
54 | cloudflareZeroTrustClientId;
55 | this.axiosInstance.defaults.headers.common['CF-Access-Client-Secret'] =
56 | cloudflareZeroTrustClientSecret;
57 | }
58 | }
59 |
60 | public async getUserByUsername(
61 | clientIp: string,
62 | username: string,
63 | ): Promise> {
64 | try {
65 | const response = await this.axiosInstance.request({
66 | method: GetUserByUsernameCommand.endpointDetails.REQUEST_METHOD,
67 | url: GetUserByUsernameCommand.url(username),
68 | headers: {
69 | [REMNAWAVE_REAL_IP_HEADER]: clientIp,
70 | },
71 | });
72 |
73 | return {
74 | isOk: true,
75 | response: response.data,
76 | };
77 | } catch (error) {
78 | if (error instanceof AxiosError) {
79 | this.logger.error('Error in Axios GetUserByUsername Request:', error.message);
80 |
81 | return {
82 | isOk: false,
83 | };
84 | } else {
85 | this.logger.error('Error in GetUserByUsername Request:', error);
86 |
87 | return {
88 | isOk: false,
89 | };
90 | }
91 | }
92 | }
93 |
94 | public async getSubscriptionInfo(
95 | clientIp: string,
96 | shortUuid: string,
97 | ): Promise> {
98 | try {
99 | const response =
100 | await this.axiosInstance.request({
101 | method: GetSubscriptionInfoByShortUuidCommand.endpointDetails.REQUEST_METHOD,
102 | url: GetSubscriptionInfoByShortUuidCommand.url(shortUuid),
103 | headers: {
104 | [REMNAWAVE_REAL_IP_HEADER]: clientIp,
105 | },
106 | });
107 |
108 | return {
109 | isOk: true,
110 | response: response.data,
111 | };
112 | } catch (error) {
113 | if (error instanceof AxiosError) {
114 | this.logger.error('Error in GetSubscriptionInfo Request:', error.message);
115 | } else {
116 | this.logger.error('Error in GetSubscriptionInfo Request:', error);
117 | }
118 |
119 | return { isOk: false };
120 | }
121 | }
122 |
123 | public async getSubscription(
124 | clientIp: string,
125 | shortUuid: string,
126 | headers: NodeJS.Dict,
127 | withClientType: boolean = false,
128 | clientType?: TRequestTemplateTypeKeys,
129 | ): Promise<{
130 | response: unknown;
131 | headers: RawAxiosResponseHeaders | AxiosResponseHeaders;
132 | } | null> {
133 | try {
134 | let basePath = 'api/sub/' + shortUuid;
135 |
136 | if (withClientType && clientType) {
137 | basePath += '/' + clientType;
138 | }
139 |
140 | const response = await this.axiosInstance.request({
141 | method: 'GET',
142 | url: basePath,
143 | headers: {
144 | ...this.filterHeaders(headers),
145 | [REMNAWAVE_REAL_IP_HEADER]: clientIp,
146 | },
147 | });
148 |
149 | return {
150 | response: response.data,
151 | headers: response.headers,
152 | };
153 | } catch (error) {
154 | if (error instanceof AxiosError) {
155 | this.logger.error('Error in GetSubscription Request:', error.message);
156 | } else {
157 | this.logger.error('Error in GetSubscription Request:', error);
158 | }
159 |
160 | return null;
161 | }
162 | }
163 |
164 | private filterHeaders(headers: NodeJS.Dict): NodeJS.Dict {
165 | const allowedHeaders = [
166 | 'user-agent',
167 | 'accept',
168 | 'accept-language',
169 | 'accept-encoding',
170 | 'x-hwid',
171 | 'x-device-os',
172 | 'x-ver-os',
173 | 'x-device-model',
174 | 'x-app-version',
175 | 'x-device-locale',
176 | 'x-client',
177 | ];
178 |
179 | const filteredHeaders = Object.fromEntries(
180 | Object.entries(headers).filter(([key]) => allowedHeaders.includes(key)),
181 | );
182 |
183 | return filteredHeaders;
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/backend/src/common/axios/index.ts:
--------------------------------------------------------------------------------
1 | export * from './axios.module';
2 | export * from './axios.service';
3 |
--------------------------------------------------------------------------------
/backend/src/common/config/app-config/config.schema.ts:
--------------------------------------------------------------------------------
1 | import { createZodDto } from 'nestjs-zod';
2 | import { z } from 'zod';
3 |
4 | export const configSchema = z
5 | .object({
6 | APP_PORT: z
7 | .string()
8 | .default('3010')
9 | .transform((port) => parseInt(port, 10)),
10 | REMNAWAVE_PANEL_URL: z.string(),
11 |
12 | MARZBAN_LEGACY_LINK_ENABLED: z
13 | .string()
14 | .default('false')
15 | .transform((val) => val === 'true'),
16 | MARZBAN_LEGACY_SECRET_KEY: z.optional(z.string()),
17 | REMNAWAVE_API_TOKEN: z.optional(z.string()),
18 |
19 | MARZBAN_LEGACY_SUBSCRIPTION_VALID_FROM: z.optional(z.string()),
20 |
21 | CUSTOM_SUB_PREFIX: z.optional(z.string()),
22 |
23 | CADDY_AUTH_API_TOKEN: z.optional(z.string()),
24 |
25 | META_TITLE: z.string(),
26 | META_DESCRIPTION: z.string(),
27 |
28 | CLOUDFLARE_ZERO_TRUST_CLIENT_ID: z.optional(z.string()),
29 | CLOUDFLARE_ZERO_TRUST_CLIENT_SECRET: z.optional(z.string()),
30 | })
31 | .superRefine((data, ctx) => {
32 | if (
33 | !data.REMNAWAVE_PANEL_URL.startsWith('http://') &&
34 | !data.REMNAWAVE_PANEL_URL.startsWith('https://')
35 | ) {
36 | ctx.addIssue({
37 | code: z.ZodIssueCode.custom,
38 | message: 'REMNAWAVE_PANEL_URL must start with http:// or https://',
39 | path: ['REMNAWAVE_PANEL_URL'],
40 | });
41 | }
42 | if (data.MARZBAN_LEGACY_LINK_ENABLED === true) {
43 | if (!data.MARZBAN_LEGACY_SECRET_KEY) {
44 | ctx.addIssue({
45 | code: z.ZodIssueCode.custom,
46 | message:
47 | 'MARZBAN_LEGACY_SECRET_KEY is required when MARZBAN_LEGACY_LINK_ENABLED is true',
48 | });
49 | }
50 | if (!data.REMNAWAVE_API_TOKEN) {
51 | ctx.addIssue({
52 | code: z.ZodIssueCode.custom,
53 | message:
54 | 'REMNAWAVE_API_TOKEN is required when MARZBAN_LEGACY_LINK_ENABLED is true',
55 | });
56 | }
57 | }
58 | });
59 |
60 | export type ConfigSchema = z.infer;
61 | export class Env extends createZodDto(configSchema) {}
62 |
--------------------------------------------------------------------------------
/backend/src/common/config/app-config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './config.schema';
2 |
--------------------------------------------------------------------------------
/backend/src/common/config/jwt/index.ts:
--------------------------------------------------------------------------------
1 | export * from './jwt.config';
2 |
--------------------------------------------------------------------------------
/backend/src/common/config/jwt/jwt.config.ts:
--------------------------------------------------------------------------------
1 | import { JwtModuleAsyncOptions } from '@nestjs/jwt';
2 |
3 | export const getJWTConfig = (): JwtModuleAsyncOptions => ({
4 | useFactory: () => ({
5 | secret: process.env.INTERNAL_JWT_SECRET,
6 | }),
7 | });
8 |
--------------------------------------------------------------------------------
/backend/src/common/constants/errors/errors.ts:
--------------------------------------------------------------------------------
1 | export const ERRORS = {
2 | INTERNAL_SERVER_ERROR: { code: 'A001', message: 'Server error', httpCode: 500 },
3 | };
4 |
--------------------------------------------------------------------------------
/backend/src/common/constants/errors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './errors';
2 |
--------------------------------------------------------------------------------
/backend/src/common/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './errors';
2 |
--------------------------------------------------------------------------------
/backend/src/common/decorators/get-ip/get-ip.ts:
--------------------------------------------------------------------------------
1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common';
2 |
3 | export const ClientIp = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
4 | const request = ctx.switchToHttp().getRequest();
5 | return request.clientIp;
6 | });
7 |
--------------------------------------------------------------------------------
/backend/src/common/decorators/get-ip/index.ts:
--------------------------------------------------------------------------------
1 | export * from './get-ip';
2 |
--------------------------------------------------------------------------------
/backend/src/common/exception/http-exeception-with-error-code.type.ts:
--------------------------------------------------------------------------------
1 | import { HttpException } from '@nestjs/common/exceptions/http.exception';
2 |
3 | export class HttpExceptionWithErrorCodeType extends HttpException {
4 | errorCode: string;
5 |
6 | constructor(message: string, errorCode: string, statusCode: number) {
7 | super(message, statusCode);
8 | this.errorCode = errorCode;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/backend/src/common/exception/httpException.filter.ts:
--------------------------------------------------------------------------------
1 | import { ZodValidationException } from 'nestjs-zod';
2 | import { Request, Response } from 'express';
3 |
4 | import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common';
5 |
6 | import { HttpExceptionWithErrorCodeType } from './http-exeception-with-error-code.type';
7 |
8 | @Catch(HttpExceptionWithErrorCodeType, ZodValidationException)
9 | export class HttpExceptionFilter implements ExceptionFilter {
10 | private readonly logger = new Logger(HttpExceptionFilter.name);
11 |
12 | catch(exception: HttpExceptionWithErrorCodeType | ZodValidationException, host: ArgumentsHost) {
13 | const ctx = host.switchToHttp();
14 | const response = ctx.getResponse();
15 | const request = ctx.getRequest();
16 | const status = exception?.getStatus();
17 |
18 | let errorMessage: string | string[];
19 | let errorCode: string = 'E000';
20 | if (status === HttpStatus.FORBIDDEN) {
21 | errorMessage = 'Forbidden';
22 | } else {
23 | errorMessage = exception.message;
24 | if (exception instanceof HttpExceptionWithErrorCodeType) {
25 | errorCode = exception.errorCode;
26 | }
27 | }
28 |
29 | if (exception instanceof ZodValidationException) {
30 | this.logger.error(exception.getResponse());
31 | response.status(status).json(exception.getResponse());
32 | } else {
33 | this.logger.error({
34 | timestamp: new Date().toISOString(),
35 | code: errorCode,
36 | path: request.url,
37 | message: errorMessage,
38 | });
39 | response.status(status).json({
40 | timestamp: new Date().toISOString(),
41 | path: request.url,
42 | message: errorMessage,
43 | errorCode,
44 | });
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/backend/src/common/exception/not-found-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
2 |
3 | @Catch(NotFoundException)
4 | export class NotFoundExceptionFilter implements ExceptionFilter {
5 | catch(exception: NotFoundException, host: ArgumentsHost) {
6 | const ctx = host.switchToHttp();
7 | const response = ctx.getResponse();
8 |
9 | response.socket?.destroy();
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/backend/src/common/guards/worker-routes/index.ts:
--------------------------------------------------------------------------------
1 | export * from './worker-routes.guard';
2 |
--------------------------------------------------------------------------------
/backend/src/common/guards/worker-routes/worker-routes.guard.ts:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs';
2 | import { Request } from 'express';
3 |
4 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
5 |
6 | @Injectable()
7 | export class WorkerRoutesGuard implements CanActivate {
8 | constructor(private readonly options: { allowedPaths: string[] } = { allowedPaths: [] }) {}
9 |
10 | canActivate(context: ExecutionContext): boolean | Promise | Observable {
11 | const request = context.switchToHttp().getRequest();
12 |
13 | if (this.options.allowedPaths.includes(request.path)) {
14 | return true;
15 | }
16 |
17 | const response = context.switchToHttp().getResponse();
18 | response.socket?.destroy();
19 |
20 | return false;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/backend/src/common/helpers/can-parse-json.ts:
--------------------------------------------------------------------------------
1 | export function canParseJSON(jsonString: string): boolean {
2 | try {
3 | JSON.parse(jsonString);
4 | return true;
5 | } catch {
6 | return false;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/backend/src/common/helpers/convert-bytes.ts:
--------------------------------------------------------------------------------
1 | export function convertBytes(
2 | bytes: number,
3 | options: { decimals?: number; useBinaryUnits?: boolean } = {},
4 | ): string {
5 | const { useBinaryUnits = false, decimals = 2 } = options;
6 |
7 | if (decimals < 0) {
8 | throw new Error(`Invalid decimals ${decimals}`);
9 | }
10 |
11 | const base = useBinaryUnits ? 1024 : 1000;
12 | const units = useBinaryUnits
13 | ? ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
14 | : ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
15 |
16 | const i = Math.floor(Math.log(bytes) / Math.log(base));
17 |
18 | return `${(bytes / Math.pow(base, i)).toFixed(decimals)} ${units[i]}`;
19 | }
20 |
--------------------------------------------------------------------------------
/backend/src/common/helpers/error-handler-with-null.helper.ts:
--------------------------------------------------------------------------------
1 | import { errorHandler } from '@common/helpers/error-handler.helper';
2 |
3 | import { ICommandResponse } from '../types/command-response.type';
4 |
5 | export function errorHandlerWithNull(response: ICommandResponse): null | T {
6 | if (response.isOk) {
7 | if (!response.response) {
8 | return null;
9 | }
10 | return errorHandler(response);
11 | } else {
12 | return errorHandler(response);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/backend/src/common/helpers/error-handler.helper.ts:
--------------------------------------------------------------------------------
1 | import { InternalServerErrorException } from '@nestjs/common';
2 |
3 | import { ERRORS } from '@common/constants';
4 |
5 | import { HttpExceptionWithErrorCodeType } from '../exception/http-exeception-with-error-code.type';
6 | import { ICommandResponse } from '../types/command-response.type';
7 |
8 | export function errorHandler(response: ICommandResponse): T {
9 | if (response.isOk) {
10 | if (!response.response) {
11 | throw new InternalServerErrorException('No data returned');
12 | }
13 | return response.response;
14 | } else {
15 | if (!response.code) {
16 | throw new InternalServerErrorException('Unknown error');
17 | }
18 | const errorObject = Object.values(ERRORS).find((error) => error.code === response.code);
19 |
20 | if (!errorObject) {
21 | throw new InternalServerErrorException('Unknown error');
22 | }
23 | throw new HttpExceptionWithErrorCodeType(
24 | response.message || errorObject.message,
25 | errorObject.code,
26 | errorObject.httpCode,
27 | );
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/backend/src/common/middlewares/check-assets-cookie.middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from 'express';
2 | import * as jwt from 'jsonwebtoken';
3 |
4 | import { Logger } from '@nestjs/common';
5 |
6 | const logger = new Logger('CheckAssetsCookieMiddleware');
7 |
8 | export function checkAssetsCookieMiddleware(req: Request, res: Response, next: NextFunction) {
9 | if (req.path.startsWith('/assets') || req.path.startsWith('/locales')) {
10 | const secret = process.env.INTERNAL_JWT_SECRET;
11 |
12 | if (!secret) {
13 | logger.error('INTERNAL_JWT_SECRET is not set');
14 | res.socket?.destroy();
15 |
16 | return;
17 | }
18 |
19 | if (!req.cookies.session) {
20 | logger.debug('No session cookie found');
21 | res.socket?.destroy();
22 |
23 | return;
24 | }
25 |
26 | try {
27 | jwt.verify(req.cookies.session, secret);
28 | } catch (error) {
29 | logger.debug(error);
30 | res.socket?.destroy();
31 |
32 | return;
33 | }
34 | }
35 |
36 | return next();
37 | }
38 |
--------------------------------------------------------------------------------
/backend/src/common/middlewares/get-real-ip.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from 'express';
2 | import { getClientIp } from '@kastov/request-ip';
3 | import morgan from 'morgan';
4 |
5 | morgan.token('remote-addr', (req: { clientIp: string } & Request) => {
6 | return req.clientIp;
7 | });
8 |
9 | export const getRealIp = function (
10 | req: { clientIp: string } & Request,
11 | res: Response,
12 | next: NextFunction,
13 | ) {
14 | const ip = getClientIp(req);
15 | if (ip) {
16 | req.clientIp = ip;
17 | } else {
18 | req.clientIp = '0.0.0.0';
19 | }
20 |
21 | next();
22 | };
23 |
--------------------------------------------------------------------------------
/backend/src/common/middlewares/index.ts:
--------------------------------------------------------------------------------
1 | export * from './check-assets-cookie.middleware';
2 | export * from './get-real-ip';
3 | export * from './no-robots.middleware';
4 | export * from './proxy-check.middleware';
5 |
--------------------------------------------------------------------------------
/backend/src/common/middlewares/no-robots.middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from 'express';
2 |
3 | export function noRobotsMiddleware(req: Request, res: Response, next: NextFunction) {
4 | res.setHeader('x-robots-tag', 'noindex, nofollow, noarchive, nosnippet, noimageindex');
5 |
6 | return next();
7 | }
8 |
--------------------------------------------------------------------------------
/backend/src/common/middlewares/proxy-check.middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from 'express';
2 |
3 | import { Logger } from '@nestjs/common';
4 |
5 | import { isDevelopment } from '@common/utils/startup-app';
6 |
7 | const logger = new Logger('ProxyCheckMiddleware');
8 |
9 | export function proxyCheckMiddleware(req: Request, res: Response, next: NextFunction) {
10 | if (isDevelopment()) {
11 | return next();
12 | }
13 |
14 | const isProxy = Boolean(req.headers['x-forwarded-for']);
15 | const isHttps = Boolean(req.headers['x-forwarded-proto'] === 'https');
16 |
17 | logger.debug(
18 | `X-Forwarded-For: ${req.headers['x-forwarded-for']}, X-Forwarded-Proto: ${req.headers['x-forwarded-proto']}`,
19 | );
20 |
21 | if (!isHttps || !isProxy) {
22 | res.socket?.destroy();
23 | logger.error('Reverse proxy and HTTPS are required.');
24 | return;
25 | }
26 |
27 | return next();
28 | }
29 |
--------------------------------------------------------------------------------
/backend/src/common/types/command-response.type.ts:
--------------------------------------------------------------------------------
1 | export interface ICommandResponse {
2 | code?: string;
3 | isOk: boolean;
4 | message?: string;
5 | response?: T;
6 | }
7 |
--------------------------------------------------------------------------------
/backend/src/common/types/converter.interface.ts:
--------------------------------------------------------------------------------
1 | export interface IConverter {
2 | fromEntitiesToPrismaModels(entities: T[]): U[];
3 | fromEntityToPrismaModel(entity: T): U;
4 | fromPrismaModelsToEntities(prismaModels: U[]): T[];
5 | fromPrismaModelToEntity(prismaModel: U): T;
6 | }
7 |
--------------------------------------------------------------------------------
/backend/src/common/types/crud-port.ts:
--------------------------------------------------------------------------------
1 | export interface ICrud {
2 | create: (entity: ENTITY) => Promise;
3 | deleteByUUID: (uuid: string) => Promise;
4 | findByCriteria: (entity: Partial) => Promise;
5 | findByUUID: (uuid: string) => Promise;
6 | update: (entity: ENTITY) => Promise;
7 | }
8 |
9 | export interface ICrudWithId {
10 | create: (entity: ENTITY) => Promise;
11 | deleteById: (id: bigint | number) => Promise;
12 | findByCriteria: (entity: Partial) => Promise;
13 | findById: (id: bigint | number) => Promise;
14 | update: (entity: ENTITY) => Promise;
15 | }
16 |
17 | export interface ICrudHistoricalRecords {
18 | create: (entity: ENTITY) => Promise;
19 | findByCriteria: (entity: Partial) => Promise;
20 | }
21 |
22 | export interface ICrudWithStringId {
23 | create: (entity: ENTITY) => Promise;
24 | deleteById: (id: string) => Promise;
25 | findByCriteria: (entity: Partial) => Promise;
26 | findById: (id: string) => Promise;
27 | update: (entity: ENTITY) => Promise;
28 | }
29 |
--------------------------------------------------------------------------------
/backend/src/common/utils/filter-logs/filter-logs.ts:
--------------------------------------------------------------------------------
1 | import winston from 'winston';
2 |
3 | const contextsToIgnore = ['InstanceLoader', 'RoutesResolver', 'RouterExplorer'];
4 |
5 | export const customLogFilter = winston.format((info) => {
6 | if (info.context) {
7 | const contextValue = String(info.context);
8 | if (contextsToIgnore.some((ctx) => contextValue === ctx)) {
9 | return false;
10 | }
11 | }
12 | return info;
13 | });
14 |
--------------------------------------------------------------------------------
/backend/src/common/utils/filter-logs/index.ts:
--------------------------------------------------------------------------------
1 | export * from './filter-logs';
2 |
--------------------------------------------------------------------------------
/backend/src/common/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './filter-logs';
2 | export * from './sanitize-username';
3 | export * from './sleep';
4 | export * from './startup-app';
5 | export * from './validate-env-config';
6 |
--------------------------------------------------------------------------------
/backend/src/common/utils/sanitize-username.ts:
--------------------------------------------------------------------------------
1 | // Reference: https://github.com/remnawave/migrate/blob/main/marzban/util/username_sanitizer.go
2 |
3 | export function sanitizeUsername(username: string): string {
4 | // Define regex pattern for valid characters
5 | const validPattern = /^[a-zA-Z0-9_-]+$/;
6 |
7 | // Create an array to store valid characters
8 | const sanitized: string[] = [];
9 |
10 | // Keep only valid characters
11 | for (const char of username) {
12 | if (validPattern.test(char)) {
13 | sanitized.push(char);
14 | } else {
15 | // Replace invalid characters with underscore
16 | sanitized.push('_');
17 | }
18 | }
19 |
20 | // Get the sanitized username
21 | let result = sanitized.join('');
22 |
23 | // Ensure minimum length of 6 characters
24 | if (result.length < 6) {
25 | result = result + '_'.repeat(6 - result.length);
26 | }
27 |
28 | return result;
29 | }
30 |
--------------------------------------------------------------------------------
/backend/src/common/utils/sleep.ts:
--------------------------------------------------------------------------------
1 | export function sleep(ms: number): Promise {
2 | return new Promise((resolve) => setTimeout(resolve, ms));
3 | }
4 |
--------------------------------------------------------------------------------
/backend/src/common/utils/startup-app/get-start-message.ts:
--------------------------------------------------------------------------------
1 | import { getBorderCharacters, table } from 'table';
2 | import { readPackageJSON } from 'pkg-types';
3 |
4 | export async function getStartMessage() {
5 | const pkg = await readPackageJSON();
6 |
7 | return table([['Docs → https://remna.st\nCommunity → https://t.me/remnawave']], {
8 | header: {
9 | content: `Remnawave Subscription Page v${pkg.version}`,
10 | alignment: 'center',
11 | },
12 | columnDefault: {
13 | width: 60,
14 | },
15 | columns: {
16 | 0: { alignment: 'center' },
17 | 1: { alignment: 'center' },
18 | },
19 | drawVerticalLine: () => false,
20 | border: getBorderCharacters('ramac'),
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/backend/src/common/utils/startup-app/index.ts:
--------------------------------------------------------------------------------
1 | export * from './get-start-message';
2 | export * from './init-log.util';
3 | export * from './is-development';
4 |
--------------------------------------------------------------------------------
/backend/src/common/utils/startup-app/init-log.util.ts:
--------------------------------------------------------------------------------
1 | import { LogLevel } from '@nestjs/common';
2 |
3 | import { isDevelopment } from './is-development';
4 |
5 | export function initLogs(): LogLevel[] {
6 | const logLevels: LogLevel[] = isDevelopment()
7 | ? ['log', 'error', 'warn', 'debug', 'verbose']
8 | : ['log', 'error', 'warn'];
9 |
10 | return logLevels;
11 | }
12 |
--------------------------------------------------------------------------------
/backend/src/common/utils/startup-app/is-development.ts:
--------------------------------------------------------------------------------
1 | export function isDevelopment(): boolean {
2 | return process.env.NODE_ENV === 'development';
3 | }
4 |
5 | export function isProduction(): boolean {
6 | return process.env.NODE_ENV === 'production';
7 | }
8 |
9 | export function isDebugLogsEnabled(): boolean {
10 | return process.env.ENABLE_DEBUG_LOGS === 'true';
11 | }
12 |
13 | export function isDevOrDebugLogsEnabled(): boolean {
14 | if (isDevelopment()) {
15 | return true;
16 | }
17 |
18 | if (isDebugLogsEnabled()) {
19 | return true;
20 | }
21 |
22 | return false;
23 | }
24 |
--------------------------------------------------------------------------------
/backend/src/common/utils/validate-env-config.ts:
--------------------------------------------------------------------------------
1 | import { z, ZodError } from 'zod';
2 |
3 | export function validateEnvConfig(schema: z.ZodType, config: Record): T {
4 | try {
5 | return schema.parse(config);
6 | } catch (e) {
7 | if (e instanceof ZodError) {
8 | const formattedErrors = e.errors
9 | .map((err) => `❌ ${err.path.join('.')}: ${err.message}`)
10 | .join('\n');
11 |
12 | const errorMessage = `
13 | 🔧 Environment Configuration Errors:
14 | ${formattedErrors}
15 |
16 | Please fix your .env file and restart the application.`;
17 |
18 | const error = new Error(errorMessage);
19 | error.stack = '';
20 | throw error;
21 | }
22 |
23 | const error = new Error(`.env configuration validation error: ${e}`);
24 | error.stack = '';
25 | throw error;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/backend/src/main.ts:
--------------------------------------------------------------------------------
1 | import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
2 | import cookieParser from 'cookie-parser';
3 | import { createLogger } from 'winston';
4 | import compression from 'compression';
5 | import * as winston from 'winston';
6 | import { nanoid } from 'nanoid';
7 | import { json } from 'express';
8 | import path from 'node:path';
9 | import helmet from 'helmet';
10 | import morgan from 'morgan';
11 |
12 | import { NestExpressApplication } from '@nestjs/platform-express';
13 | import { ConfigService } from '@nestjs/config';
14 | import { NestFactory } from '@nestjs/core';
15 |
16 | import { checkAssetsCookieMiddleware } from '@common/middlewares/check-assets-cookie.middleware';
17 | import { NotFoundExceptionFilter } from '@common/exception/not-found-exception.filter';
18 | import { isDevelopment, isDevOrDebugLogsEnabled } from '@common/utils/startup-app';
19 | import { noRobotsMiddleware, proxyCheckMiddleware } from '@common/middlewares';
20 | import { getStartMessage } from '@common/utils/startup-app/get-start-message';
21 | import { customLogFilter } from '@common/utils/filter-logs/filter-logs';
22 | import { getRealIp } from '@common/middlewares/get-real-ip';
23 |
24 | import { AppModule } from './app.module';
25 |
26 | // const levels = {
27 | // error: 0,
28 | // warn: 1,
29 | // info: 2,
30 | // http: 3,
31 | // verbose: 4,
32 | // debug: 5,
33 | // silly: 6,
34 | // };
35 |
36 | process.env.INTERNAL_JWT_SECRET = nanoid(64);
37 |
38 | const instanceId = process.env.INSTANCE_ID || '0';
39 |
40 | const logger = createLogger({
41 | transports: [new winston.transports.Console()],
42 | format: winston.format.combine(
43 | customLogFilter(),
44 | winston.format.timestamp({
45 | format: 'YYYY-MM-DD HH:mm:ss.SSS',
46 | }),
47 | winston.format.ms(),
48 | nestWinstonModuleUtilities.format.nestLike(`#${instanceId}`, {
49 | colors: true,
50 | prettyPrint: true,
51 | processId: false,
52 | appName: true,
53 | }),
54 | ),
55 | level: isDevOrDebugLogsEnabled() ? 'debug' : 'http',
56 | });
57 |
58 | const assetsPath = isDevelopment()
59 | ? path.join(__dirname, '..', '..', 'dev_frontend')
60 | : '/opt/app/frontend';
61 |
62 | async function bootstrap(): Promise {
63 | const app = await NestFactory.create(AppModule, {
64 | logger: WinstonModule.createLogger({
65 | instance: logger,
66 | }),
67 | });
68 |
69 | app.disable('x-powered-by');
70 |
71 | app.use(cookieParser());
72 |
73 | app.use(noRobotsMiddleware, proxyCheckMiddleware, checkAssetsCookieMiddleware, getRealIp);
74 |
75 | app.useGlobalFilters(new NotFoundExceptionFilter());
76 |
77 | app.useStaticAssets(assetsPath, {
78 | index: false,
79 | });
80 |
81 | app.setBaseViewsDir(assetsPath);
82 |
83 | // eslint-disable-next-line @typescript-eslint/no-require-imports
84 | const consolidate = require('@ladjs/consolidate');
85 |
86 | app.engine('html', consolidate.ejs);
87 | app.setViewEngine('html');
88 |
89 | app.use(json({ limit: '100mb' }));
90 |
91 | const config = app.get(ConfigService);
92 |
93 | app.use(helmet({ contentSecurityPolicy: false }));
94 |
95 | app.use(compression());
96 |
97 | app.use(
98 | morgan(
99 | ':remote-addr - ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"',
100 | ),
101 | );
102 |
103 | app.setGlobalPrefix(config.get('CUSTOM_SUB_PREFIX') || '');
104 |
105 | app.enableCors({
106 | origin: '*',
107 | methods: 'GET',
108 | credentials: false,
109 | });
110 |
111 | app.enableShutdownHooks();
112 |
113 | await app.listen(Number(config.getOrThrow('APP_PORT')));
114 |
115 | logger.info('\n' + (await getStartMessage()) + '\n');
116 | }
117 | void bootstrap();
118 |
--------------------------------------------------------------------------------
/backend/src/modules/root/root.controller.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 |
3 | import { Get, Controller, Res, Req, Param, Logger } from '@nestjs/common';
4 |
5 | import {
6 | REQUEST_TEMPLATE_TYPE_VALUES,
7 | TRequestTemplateTypeKeys,
8 | } from '@remnawave/backend-contract';
9 |
10 | import { ClientIp } from '@common/decorators/get-ip';
11 |
12 | import { RootService } from './root.service';
13 |
14 | @Controller()
15 | export class RootController {
16 | private readonly logger = new Logger(RootController.name);
17 |
18 | constructor(private readonly rootService: RootService) {}
19 | @Get([':shortUuid', ':shortUuid/:clientType'])
20 | async root(
21 | @ClientIp() clientIp: string,
22 | @Req() request: Request,
23 | @Res() response: Response,
24 | @Param('shortUuid') shortUuid: string,
25 | @Param('clientType') clientType: string,
26 | ) {
27 | if (request.path.startsWith('/assets') || request.path.startsWith('/locales')) {
28 | response.socket?.destroy();
29 | return;
30 | }
31 |
32 | if (clientType === undefined) {
33 | return await this.rootService.serveSubscriptionPage(
34 | clientIp,
35 | request,
36 | response,
37 | shortUuid,
38 | );
39 | }
40 |
41 | if (!REQUEST_TEMPLATE_TYPE_VALUES.includes(clientType as TRequestTemplateTypeKeys)) {
42 | this.logger.error(`Invalid client type: ${clientType}`);
43 |
44 | response.socket?.destroy();
45 | return;
46 | } else {
47 | return await this.rootService.serveSubscriptionPage(
48 | clientIp,
49 | request,
50 | response,
51 | shortUuid,
52 | clientType as TRequestTemplateTypeKeys,
53 | );
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/backend/src/modules/root/root.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { JwtModule } from '@nestjs/jwt';
3 |
4 | import { getJWTConfig } from '@common/config/jwt/jwt.config';
5 |
6 | import { RootController } from './root.controller';
7 | import { RootService } from './root.service';
8 |
9 | @Module({
10 | imports: [JwtModule.registerAsync(getJWTConfig())],
11 | controllers: [RootController],
12 | providers: [RootService],
13 | })
14 | export class RootModule {}
15 |
--------------------------------------------------------------------------------
/backend/src/modules/root/root.service.ts:
--------------------------------------------------------------------------------
1 | import { RawAxiosResponseHeaders } from 'axios';
2 | import { AxiosResponseHeaders } from 'axios';
3 | import { Request, Response } from 'express';
4 | import { createHash } from 'node:crypto';
5 | import { nanoid } from 'nanoid';
6 |
7 | import { ConfigService } from '@nestjs/config';
8 | import { Injectable } from '@nestjs/common';
9 | import { JwtService } from '@nestjs/jwt';
10 | import { Logger } from '@nestjs/common';
11 |
12 | import { TRequestTemplateTypeKeys } from '@remnawave/backend-contract';
13 |
14 | import { AxiosService } from '@common/axios/axios.service';
15 | import { sanitizeUsername } from '@common/utils';
16 |
17 | @Injectable()
18 | export class RootService {
19 | private readonly logger = new Logger(RootService.name);
20 |
21 | private readonly isMarzbanLegacyLinkEnabled: boolean;
22 | private readonly marzbanSecretKey?: string;
23 |
24 | constructor(
25 | private readonly configService: ConfigService,
26 | private readonly jwtService: JwtService,
27 | private readonly axiosService: AxiosService,
28 | ) {
29 | this.isMarzbanLegacyLinkEnabled = this.configService.getOrThrow(
30 | 'MARZBAN_LEGACY_LINK_ENABLED',
31 | );
32 | this.marzbanSecretKey = this.configService.get('MARZBAN_LEGACY_SECRET_KEY');
33 | }
34 |
35 | public async serveSubscriptionPage(
36 | clientIp: string,
37 | req: Request,
38 | res: Response,
39 | shortUuid: string,
40 | clientType?: TRequestTemplateTypeKeys,
41 | ): Promise {
42 | try {
43 | const userAgent = req.headers['user-agent'];
44 |
45 | let shortUuidLocal = shortUuid;
46 |
47 | if (this.isGenericPath(req.path)) {
48 | res.socket?.destroy();
49 | return;
50 | }
51 |
52 | if (this.isMarzbanLegacyLinkEnabled) {
53 | const username = await this.decodeMarzbanLink(shortUuid);
54 |
55 | if (username) {
56 | const sanitizedUsername = sanitizeUsername(username.username);
57 |
58 | this.logger.log(
59 | `Decoded Marzban username: ${username.username}, sanitized username: ${sanitizedUsername}`,
60 | );
61 |
62 | const userInfo = await this.axiosService.getUserByUsername(
63 | clientIp,
64 | sanitizedUsername,
65 | );
66 | if (!userInfo.isOk || !userInfo.response) {
67 | this.logger.error(
68 | `Decoded Marzban username is not found in Remnawave, decoded username: ${sanitizedUsername}`,
69 | );
70 |
71 | res.socket?.destroy();
72 | return;
73 | }
74 |
75 | shortUuidLocal = userInfo.response.response.shortUuid;
76 | }
77 | }
78 |
79 | if (userAgent && this.isBrowser(userAgent)) {
80 | return this.returnWebpage(clientIp, req, res, shortUuidLocal);
81 | }
82 |
83 | let subscriptionDataResponse: {
84 | response: unknown;
85 | headers: RawAxiosResponseHeaders | AxiosResponseHeaders;
86 | } | null = null;
87 |
88 | subscriptionDataResponse = await this.axiosService.getSubscription(
89 | clientIp,
90 | shortUuidLocal,
91 | req.headers,
92 | !!clientType,
93 | clientType,
94 | );
95 |
96 | if (!subscriptionDataResponse) {
97 | res.socket?.destroy();
98 | return;
99 | }
100 |
101 | if (subscriptionDataResponse.headers) {
102 | Object.entries(subscriptionDataResponse.headers)
103 | .filter(([key]) => {
104 | const ignoredHeaders = ['transfer-encoding', 'content-length', 'server'];
105 | return !ignoredHeaders.includes(key.toLowerCase());
106 | })
107 | .forEach(([key, value]) => {
108 | res.setHeader(key, value);
109 | });
110 | }
111 |
112 | res.status(200).send(subscriptionDataResponse.response);
113 | } catch (error) {
114 | this.logger.error('Error in serveSubscriptionPage', error);
115 |
116 | res.socket?.destroy();
117 | return;
118 | }
119 | }
120 |
121 | private async generateJwtForCookie(): Promise {
122 | return this.jwtService.sign(
123 | {
124 | sessionId: nanoid(32),
125 | },
126 | {
127 | expiresIn: '1h',
128 | },
129 | );
130 | }
131 |
132 | private isBrowser(userAgent: string): boolean {
133 | const browserKeywords = [
134 | 'Mozilla',
135 | 'Chrome',
136 | 'Safari',
137 | 'Firefox',
138 | 'Opera',
139 | 'Edge',
140 | 'TelegramBot',
141 | ];
142 |
143 | return browserKeywords.some((keyword) => userAgent.includes(keyword));
144 | }
145 |
146 | private isGenericPath(path: string): boolean {
147 | const genericPaths = ['favicon.ico', 'robots.txt'];
148 |
149 | return genericPaths.some((genericPath) => path.includes(genericPath));
150 | }
151 |
152 | private async returnWebpage(
153 | clientIp: string,
154 | req: Request,
155 | res: Response,
156 | shortUuid: string,
157 | ): Promise {
158 | try {
159 | const cookieJwt = await this.generateJwtForCookie();
160 |
161 | const subscriptionDataResponse = await this.axiosService.getSubscriptionInfo(
162 | clientIp,
163 | shortUuid,
164 | );
165 |
166 | if (!subscriptionDataResponse.isOk) {
167 | this.logger.error(`Get subscription info failed, shortUuid: ${shortUuid}`);
168 |
169 | res.socket?.destroy();
170 | return;
171 | }
172 |
173 | const subscriptionData = subscriptionDataResponse.response;
174 |
175 | res.cookie('session', cookieJwt, {
176 | httpOnly: true,
177 | secure: true,
178 | maxAge: 3_600_000, // 1 hour
179 | });
180 |
181 | res.render('index', {
182 | metaTitle: this.configService
183 | .getOrThrow('META_TITLE')
184 | .replace(/^"|"$/g, ''),
185 | metaDescription: this.configService
186 | .getOrThrow('META_DESCRIPTION')
187 | .replace(/^"|"$/g, ''),
188 | panelData: Buffer.from(JSON.stringify(subscriptionData)).toString('base64'),
189 | });
190 | } catch (error) {
191 | this.logger.error('Error in returnWebpage', error);
192 |
193 | res.socket?.destroy();
194 | return;
195 | }
196 | }
197 |
198 | private async decodeMarzbanLink(shortUuid: string): Promise<{
199 | username: string;
200 | createdAt: Date;
201 | } | null> {
202 | const token = shortUuid;
203 | this.logger.debug(`Verifying token: ${token}`);
204 |
205 | if (!token || token.length < 10) {
206 | this.logger.debug(`Token too short: ${token}`);
207 | return null;
208 | }
209 |
210 | if (token.split('.').length === 3) {
211 | try {
212 | const payload = await this.jwtService.verifyAsync(token, {
213 | secret: this.marzbanSecretKey!,
214 | algorithms: ['HS256'],
215 | });
216 |
217 | if (payload.access !== 'subscription') {
218 | throw new Error('JWT access field is not subscription');
219 | }
220 |
221 | const jwtCreatedAt = new Date(payload.iat * 1000);
222 |
223 | if (!this.checkSubscriptionValidity(jwtCreatedAt, payload.sub)) {
224 | return null;
225 | }
226 |
227 | this.logger.debug(`JWT verified successfully, ${JSON.stringify(payload)}`);
228 |
229 | return {
230 | username: payload.sub,
231 | createdAt: jwtCreatedAt,
232 | };
233 | } catch (err) {
234 | this.logger.debug(`JWT verification failed: ${err}`);
235 | }
236 | }
237 |
238 | const uToken = token.slice(0, token.length - 10);
239 | const uSignature = token.slice(token.length - 10);
240 |
241 | this.logger.debug(`Token parts: base: ${uToken}, signature: ${uSignature}`);
242 |
243 | let decoded: string;
244 | try {
245 | decoded = Buffer.from(uToken, 'base64url').toString();
246 | } catch (err) {
247 | this.logger.debug(`Base64 decode error: ${err}`);
248 | return null;
249 | }
250 |
251 | const hash = createHash('sha256');
252 | hash.update(uToken + this.marzbanSecretKey!);
253 | const digest = hash.digest();
254 |
255 | const expectedSignature = Buffer.from(digest).toString('base64url').slice(0, 10);
256 |
257 | this.logger.debug(`Expected signature: ${expectedSignature}, actual: ${uSignature}`);
258 |
259 | if (uSignature !== expectedSignature) {
260 | this.logger.debug('Signature mismatch');
261 | return null;
262 | }
263 |
264 | const parts = decoded.split(',');
265 | if (parts.length < 2) {
266 | this.logger.debug(`Invalid token format: ${decoded}`);
267 | return null;
268 | }
269 |
270 | const username = parts[0];
271 | const createdAtInt = parseInt(parts[1], 10);
272 |
273 | if (isNaN(createdAtInt)) {
274 | this.logger.debug(`Invalid created_at timestamp: ${parts[1]}`);
275 | return null;
276 | }
277 |
278 | const createdAt = new Date(createdAtInt * 1000);
279 |
280 | if (!this.checkSubscriptionValidity(createdAt, username)) {
281 | return null;
282 | }
283 |
284 | this.logger.debug(`Token decoded. Username: ${username}, createdAt: ${createdAt}`);
285 |
286 | return {
287 | username,
288 | createdAt,
289 | };
290 | }
291 |
292 | private checkSubscriptionValidity(createdAt: Date, username: string): boolean {
293 | const validFrom = this.configService.get(
294 | 'MARZBAN_LEGACY_SUBSCRIPTION_VALID_FROM',
295 | );
296 |
297 | if (!validFrom) {
298 | return true;
299 | }
300 |
301 | const validFromDate = new Date(validFrom);
302 | if (createdAt < validFromDate) {
303 | this.logger.debug(
304 | `createdAt JWT: ${createdAt.toISOString()} is before validFrom: ${validFromDate.toISOString()}`,
305 | );
306 |
307 | this.logger.warn(
308 | `${JSON.stringify({ username, createdAt })} – subscription createdAt is before validFrom`,
309 | );
310 |
311 | return false;
312 | }
313 |
314 | return true;
315 | }
316 | }
317 |
--------------------------------------------------------------------------------
/backend/src/modules/subscription-page-backend.modules.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { RootModule } from './root/root.module';
4 |
5 | @Module({
6 | imports: [RootModule],
7 | })
8 | export class SubscriptionPageBackendModule {}
9 |
--------------------------------------------------------------------------------
/backend/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "NodeNext",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "esModuleInterop": true,
8 | "experimentalDecorators": true,
9 | "allowSyntheticDefaultImports": true,
10 | "moduleResolution": "NodeNext",
11 | "target": "ESNext",
12 | "sourceMap": true,
13 | "resolveJsonModule": true,
14 | "outDir": "./dist",
15 | "baseUrl": "./",
16 | "paths": {
17 | "@common/*": [
18 | "./src/common/*"
19 | ],
20 | "@integration-modules/*": [
21 | "./src/integration-modules/*"
22 | ],
23 | "@modules/*": [
24 | "./src/modules/*"
25 | ],
26 | "@libs/contracts/*": [
27 | "./libs/contract/*"
28 | ],
29 | "@contract/*": [
30 | "./libs/contract/*"
31 | ],
32 | "@queue/*": [
33 | "./src/queue/*"
34 | ],
35 | "@scheduler/*": [
36 | "./src/scheduler/*"
37 | ]
38 | },
39 | "incremental": true,
40 | "skipLibCheck": true,
41 | "strictNullChecks": true,
42 | "strict": true,
43 | "strictPropertyInitialization": false,
44 | "noImplicitAny": true,
45 | "strictBindCallApply": false,
46 | "forceConsistentCasingInFileNames": false,
47 | "noFallthroughCasesInSwitch": false,
48 | "useDefineForClassFields": true
49 | }
50 | }
--------------------------------------------------------------------------------
/docker-compose-prod.yml:
--------------------------------------------------------------------------------
1 | services:
2 | remnawave-subscription-page:
3 | image: remnawave/subscription-page:latest
4 | container_name: remnawave-subscription-page
5 | hostname: remnawave-subscription-page
6 | restart: always
7 | env_file:
8 | - .env
9 | ports:
10 | - '127.0.0.1:3010:3010'
11 | networks:
12 | - remnawave-network
13 |
14 | networks:
15 | remnawave-network:
16 | driver: bridge
17 | external: true
18 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | remnawave-subscription-page:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | env_file:
7 | - .env
8 | ports:
9 | - '127.0.0.1:3010:3010'
10 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: { browser: true, es2020: true },
3 | extends: [
4 | 'eslint:recommended',
5 | 'airbnb-base',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | 'plugin:storybook/recommended',
9 | 'plugin:perfectionist/recommended-natural-legacy',
10 | 'prettier'
11 | ],
12 | ignorePatterns: ['dist', '.eslintrc.cjs', 'plop', 'plop/**', 'plopfile.js', '.stylelintrc.js'],
13 | plugins: ['react-refresh', 'import'],
14 | parser: '@typescript-eslint/parser',
15 | settings: {
16 | 'import/parsers': {
17 | '@typescript-eslint/parser': ['.ts', '.tsx']
18 | },
19 | 'import/resolver': {
20 | node: true,
21 | typescript: {
22 | project: './frontend/tsconfig.json'
23 | }
24 | }
25 | },
26 |
27 | rules: {
28 | 'perfectionist/sort-imports': [
29 | 'error',
30 | {
31 | type: 'line-length',
32 | order: 'desc',
33 | ignoreCase: true,
34 | specialCharacters: 'keep',
35 | internalPattern: ['^~/.+'],
36 | tsconfigRootDir: '.',
37 | partitionByComment: false,
38 | partitionByNewLine: false,
39 | newlinesBetween: 'always',
40 | maxLineLength: undefined,
41 | groups: [
42 | 'type',
43 | ['builtin', 'external'],
44 | 'internal-type',
45 | 'internal',
46 | ['parent-type', 'sibling-type', 'index-type'],
47 | ['parent', 'sibling', 'index'],
48 | 'object',
49 | 'unknown'
50 | ],
51 | customGroups: { type: {}, value: {} },
52 | environment: 'node'
53 | }
54 | ],
55 | 'perfectionist/sort-objects': ['off'],
56 | 'perfectionist/sort-interfaces': ['off'],
57 | 'perfectionist/sort-modules': ['off'],
58 | indent: ['error', 4, { SwitchCase: 1 }],
59 | 'max-classes-per-file': 'off',
60 | 'import/no-extraneous-dependencies': ['off'],
61 | 'import/no-unresolved': 'error',
62 | 'import/prefer-default-export': 'off',
63 | 'import/extensions': 'off',
64 | 'no-bitwise': 'off',
65 | 'no-plusplus': 'off',
66 | 'no-restricted-syntax': ['off', 'ForInStatement'],
67 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
68 | 'no-shadow': ['off'],
69 | 'arrow-body-style': ['off'],
70 | 'object-curly-spacing': ['error', 'always'],
71 | 'array-bracket-spacing': ['error', 'never'],
72 | 'no-underscore-dangle': [
73 | 'off',
74 | {
75 | allow: ['_'],
76 | allowAfterThis: true,
77 | allowAfterSuper: true,
78 | allowAfterThisConstructor: true,
79 | enforceInMethodNames: false
80 | }
81 | ],
82 | semi: ['error', 'never'],
83 | 'comma-dangle': ['off'],
84 | 'brace-style': ['error', '1tbs', { allowSingleLine: true }],
85 | 'object-curly-newline': ['error', { multiline: true, consistent: true }],
86 | 'react-hooks/exhaustive-deps': 'off',
87 | 'no-empty-pattern': 'warn',
88 | '@typescript-eslint/ban-types': [
89 | 'error',
90 | {
91 | types: {
92 | '{}': false
93 | }
94 | }
95 | ]
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/frontend/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 |
--------------------------------------------------------------------------------
/frontend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "singleAttributePerLine": false,
4 | "tabWidth": 4,
5 | "printWidth": 100,
6 | "semi": false,
7 | "trailingComma": "none"
8 | }
--------------------------------------------------------------------------------
/frontend/.stylelintignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/frontend/.stylelintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["stylelint-config-standard-scss"],
3 | "rules": {
4 | "custom-property-pattern": null,
5 | "selector-class-pattern": null,
6 | "scss/no-duplicate-mixins": null,
7 | "declaration-empty-line-before": null,
8 | "declaration-block-no-redundant-longhand-properties": null,
9 | "alpha-value-notation": null,
10 | "custom-property-empty-line-before": null,
11 | "property-no-vendor-prefix": null,
12 | "color-function-notation": null,
13 | "length-zero-no-unit": null,
14 | "selector-not-notation": null,
15 | "no-descending-specificity": null,
16 | "comment-empty-line-before": null,
17 | "scss/at-mixin-pattern": null,
18 | "scss/at-rule-no-unknown": null,
19 | "value-keyword-case": null,
20 | "media-feature-range-notation": null,
21 | "selector-pseudo-class-no-unknown": [
22 | true,
23 | {
24 | "ignorePseudoClasses": ["global"]
25 | }
26 | ]
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/@types/i18n.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * If you want to enable locale keys typechecking and enhance IDE experience.
3 | *
4 | * Requires `resolveJsonModule:true` in your tsconfig.json.
5 | *
6 | * @link https://www.i18next.com/overview/typescript
7 | */
8 | import 'i18next'
9 |
10 | // resources.ts file is generated with `npm run toc`
11 | import resources from './resources.ts'
12 |
13 | declare module 'i18next' {
14 | interface CustomTypeOptions {
15 | defaultNS: 'main'
16 | resources: typeof resources
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/@types/resources.ts:
--------------------------------------------------------------------------------
1 | import main from '../public/locales/en/main.json'
2 |
3 | const resources = {
4 | main
5 | } as const
6 |
7 | export default resources
8 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
17 |
18 |
19 |
20 |
24 |
25 |
26 | <%= metaTitle %>
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@remnawave/subscription-page",
3 | "private": false,
4 | "type": "module",
5 | "version": "6.0.8",
6 | "license": "AGPL-3.0-only",
7 | "author": "REMNAWAVE ",
8 | "homepage": "https://github.com/remnawave",
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/remnawave/subscription-page"
12 | },
13 | "bugs": {
14 | "url": "https://github.com/remnawave/subscription-page/issues"
15 | },
16 | "scripts": {
17 | "start:dev": "vite",
18 | "start:build": "NODE_ENV=production tsc && vite build",
19 | "cb": "vite build",
20 | "start:preview": "vite preview --port 3334",
21 | "serve": "NODE_ENV=production tsc && vite build && vite preview --port 3334",
22 | "serve:dev": "cross-env NODE_ENV=production DOMAIN_OVERRIDE=1 tsc && vite build && vite preview --port 3334",
23 | "typecheck": "tsc --noEmit",
24 | "lint": "npm run lint:eslint && npm run lint:stylelint",
25 | "lint:eslint": "eslint . --ext .ts,.tsx --cache",
26 | "lint:stylelint": "stylelint '**/*.css' --cache",
27 | "prettier": "prettier --check \"**/*.{ts,tsx}\"",
28 | "prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
29 | "toc": "i18next-resources-for-ts toc -i ./public/locales/en -o @types/resources.ts"
30 | },
31 | "dependencies": {
32 | "@mantine/core": "8.2.2",
33 | "@mantine/dates": "8.2.2",
34 | "@mantine/hooks": "8.2.2",
35 | "@mantine/modals": "8.2.2",
36 | "@mantine/notifications": "8.2.2",
37 | "@mantine/nprogress": "8.2.2",
38 | "@remnawave/backend-contract": "2.1.1",
39 | "@tabler/icons-react": "^3.34.1",
40 | "clsx": "^2.1.1",
41 | "color-hash": "^2.0.2",
42 | "consola": "^3.4.2",
43 | "country-flag-emoji-polyfill": "^0.1.8",
44 | "dayjs": "^1.11.13",
45 | "ejs": "^3.1.10",
46 | "i18next": "^25.3.2",
47 | "i18next-browser-languagedetector": "8.2.0",
48 | "i18next-http-backend": "^3.0.2",
49 | "motion": "^11.13.1",
50 | "ofetch": "^1.4.1",
51 | "react": "19.1.1",
52 | "react-dom": "19.1.1",
53 | "react-error-boundary": "6.0.0",
54 | "react-i18next": "^15.6.1",
55 | "react-icons": "^5.5.0",
56 | "react-imask": "^7.6.1",
57 | "react-router-dom": "6.27.0",
58 | "tiny-invariant": "^1.3.3",
59 | "ufo": "^1.6.1",
60 | "uqr": "^0.1.2",
61 | "xbytes": "^1.9.1",
62 | "zod": "^3.23.8",
63 | "zustand": "^5.0.7"
64 | },
65 | "devDependencies": {
66 | "vite-plugin-deadfile": "^1.3.0",
67 | "@eslint/js": "^9.16.0",
68 | "@ianvs/prettier-plugin-sort-imports": "^4.3.1",
69 | "@types/byte-size": "^8.1.2",
70 | "@types/bytes": "^3.1.4",
71 | "@types/color-hash": "^2.0.0",
72 | "@types/crypto-js": "^4.2.2",
73 | "@types/mdx": "^2.0.13",
74 | "@types/node": "^24.1.0",
75 | "@types/react": "^19.1.9",
76 | "@types/react-dom": "^19.1.7",
77 | "@typescript-eslint/eslint-plugin": "^7.0.2",
78 | "@typescript-eslint/parser": "^7.0.2",
79 | "@vitejs/plugin-react": "^4.3.1",
80 | "@vitejs/plugin-react-swc": "^3.7.0",
81 | "cross-env": "^7.0.3",
82 | "dependency-cruiser": "^16.7.0",
83 | "eslint": "^8.56.0",
84 | "eslint-config-airbnb-base": "^15.0.0",
85 | "eslint-config-prettier": "^9.1.0",
86 | "eslint-import-resolver-typescript": "^3.6.1",
87 | "eslint-plugin-import": "^2.29.0",
88 | "eslint-plugin-perfectionist": "^4.1.2",
89 | "eslint-plugin-react-hooks": "^4.6.0",
90 | "eslint-plugin-react-refresh": "^0.4.5",
91 | "eslint-plugin-storybook": "^0.8.0",
92 | "i18next-resources-for-ts": "^1.5.0",
93 | "identity-obj-proxy": "^3.0.0",
94 | "jsdom": "^25.0.0",
95 | "postcss": "^8.4.45",
96 | "postcss-preset-mantine": "1.17.0",
97 | "postcss-simple-vars": "^7.0.1",
98 | "prettier": "^3.3.3",
99 | "prop-types": "^15.8.1",
100 | "rollup-plugin-visualizer": "^5.12.0",
101 | "steiger": "^0.5.3",
102 | "stylelint": "^16.9.0",
103 | "stylelint-config-standard-scss": "^13.1.0",
104 | "typesafe-i18n": "^5.26.2",
105 | "typescript": "~5.8.3",
106 | "typescript-eslint": "^8.16.0",
107 | "vite": "6.1.1",
108 | "vite-plugin-javascript-obfuscator": "^3.1.0",
109 | "vite-plugin-preload": "^0.4.0",
110 | "vite-plugin-remove-console": "^2.2.0",
111 | "vite-plugin-webfont-dl": "^3.10.4",
112 | "vite-tsconfig-paths": "^5.0.1"
113 | },
114 | "commitlint": {
115 | "extends": [
116 | "@commitlint/config-conventional"
117 | ]
118 | },
119 | "optionalDependencies": {
120 | "@rollup/rollup-linux-x64-gnu": "4.9.5"
121 | },
122 | "overrides": {
123 | "node-plop": {
124 | "inquirer": "9.3.5"
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/frontend/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-preset-mantine': {
4 | autoRem: true
5 | },
6 | 'postcss-simple-vars': {
7 | variables: {
8 | 'mantine-breakpoint-xs': '30em',
9 | 'mantine-breakpoint-sm': '40em',
10 | 'mantine-breakpoint-md': '48em',
11 | 'mantine-breakpoint-lg': '64em',
12 | 'mantine-breakpoint-xl': '80em',
13 | 'mantine-breakpoint-2xl': '96em',
14 | 'mantine-breakpoint-3xl': '120em',
15 | 'mantine-breakpoint-4xl': '160em'
16 | }
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/public/assets/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remnawave/subscription-page/b212ae7d430e749fc7839c780bd9fc1b5c4e0bec/frontend/public/assets/android-chrome-192x192.png
--------------------------------------------------------------------------------
/frontend/public/assets/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remnawave/subscription-page/b212ae7d430e749fc7839c780bd9fc1b5c4e0bec/frontend/public/assets/android-chrome-512x512.png
--------------------------------------------------------------------------------
/frontend/public/assets/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remnawave/subscription-page/b212ae7d430e749fc7839c780bd9fc1b5c4e0bec/frontend/public/assets/apple-touch-icon.png
--------------------------------------------------------------------------------
/frontend/public/assets/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remnawave/subscription-page/b212ae7d430e749fc7839c780bd9fc1b5c4e0bec/frontend/public/assets/favicon-16x16.png
--------------------------------------------------------------------------------
/frontend/public/assets/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remnawave/subscription-page/b212ae7d430e749fc7839c780bd9fc1b5c4e0bec/frontend/public/assets/favicon-32x32.png
--------------------------------------------------------------------------------
/frontend/public/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remnawave/subscription-page/b212ae7d430e749fc7839c780bd9fc1b5c4e0bec/frontend/public/assets/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/locales/en/main.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": {
3 | "page": {
4 | "component": {
5 | "podpiska": "Subscription"
6 | }
7 | }
8 | },
9 | "installation-guide": {
10 | "widget": {
11 | "add-subscription": "Add subscription",
12 | "add-subscription-button": "Add subscription",
13 | "connect-and-use": "Connect and use",
14 | "installation": "Installation",
15 | "select-device": "Select device"
16 | },
17 | "ios": {
18 | "widget": {
19 | "install-and-open-app": "Install and open {{appName}}"
20 | }
21 | },
22 | "android": {
23 | "widget": {
24 | "install-and-open-app": "Install and open {{appName}}"
25 | }
26 | },
27 | "windows": {
28 | "widget": {
29 | "download-app": "Download {{appName}}"
30 | }
31 | },
32 | "linux": {
33 | "widget": {
34 | "download-app": "Download {{appName}}"
35 | }
36 | },
37 | "macos": {
38 | "widget": {
39 | "download-app": "Download {{appName}}"
40 | }
41 | }
42 | },
43 | "subscription-link": {
44 | "widget": {
45 | "link-copied": "Link copied",
46 | "link-copied-to-clipboard": "Link copied to clipboard",
47 | "get-link": "Get link",
48 | "scan-qr-code": "Scan the QR code above in the client",
49 | "line-1": "Easily add the subscription to any client. There's another option: copy the link below and paste it into the client",
50 | "copy-link": "Copy link"
51 | }
52 | },
53 | "subscription-info": {
54 | "widget": {
55 | "name": "Username",
56 | "status": "Status",
57 | "active": "Active",
58 | "inactive": "Inactive",
59 | "expires": "Expires",
60 | "at": "At",
61 | "bandwidth": "Bandwidth"
62 | }
63 | },
64 | "get-expiration-text": {
65 | "util": {
66 | "expires-in": "Expires {{expiration}}",
67 | "expired": "Expired {{expiration}}",
68 | "unknown": "Unknown",
69 | "indefinitely": "Indefinitely"
70 | }
71 | }
72 | }
--------------------------------------------------------------------------------
/frontend/public/locales/fa/main.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": {
3 | "page": {
4 | "component": {
5 | "podpiska": "اشتراک"
6 | }
7 | }
8 | },
9 | "installation-guide": {
10 | "widget": {
11 | "add-subscription": "افزودن اشتراک",
12 | "add-subscription-button": "افزودن اشتراک",
13 | "connect-and-use": "اتصال و استفاده",
14 | "installation": "نصب",
15 | "select-device": "انتخاب دستگاه",
16 | "pc": "رایانه"
17 | },
18 | "ios": {
19 | "widget": {
20 | "install-and-open-app": "نصب و باز کردن {{appName}}"
21 | }
22 | },
23 | "android": {
24 | "widget": {
25 | "install-and-open-app": "نصب و باز کردن {{appName}}"
26 | }
27 | },
28 | "pc": {
29 | "widget": {
30 | "download-app": "دانلود {{appName}}"
31 | }
32 | },
33 | "macos": {
34 | "widget": {
35 | "download-app": "دانلود {{appName}}"
36 | }
37 | },
38 | "windows": {
39 | "widget": {
40 | "download-app": "دانلود {{appName}}"
41 | }
42 | }
43 | },
44 | "subscription-link": {
45 | "widget": {
46 | "link-copied": "لینک کپی شد",
47 | "link-copied-to-clipboard": "لینک به کلیپبورد کپی شد",
48 | "get-link": "دریافت لینک",
49 | "scan-qr-code": "کد QR بالا را در کلاینت اسکن کنید",
50 | "line-1": "افزودن آسان اشتراک به هر کلاینت. گزینه دیگری هم وجود دارد: لینک زیر را کپی کرده و در کلاینت جایگذاری کنید",
51 | "copy-link": "کپی لینک"
52 | }
53 | },
54 | "subscription-info": {
55 | "widget": {
56 | "name": "نام کاربری",
57 | "status": "وضعیت",
58 | "active": "فعال",
59 | "inactive": "غیرفعال",
60 | "expires": "منقضی میشود",
61 | "at": "در",
62 | "bandwidth": "پهنای باند"
63 | }
64 | },
65 | "get-expiration-text": {
66 | "util": {
67 | "expires-in": "منقضی میشود در {{expiration}}",
68 | "expired": "منقضی شده در {{expiration}}",
69 | "unknown": "نامعلوم",
70 | "indefinitely": "هیچوقت"
71 | }
72 | }
73 | }
--------------------------------------------------------------------------------
/frontend/public/locales/ru/main.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": {
3 | "page": {
4 | "component": {
5 | "podpiska": "Подписка"
6 | }
7 | }
8 | },
9 | "installation-guide": {
10 | "widget": {
11 | "add-subscription": "Добавить подписку",
12 | "add-subscription-button": "Добавить подписку",
13 | "connect-and-use": "Подключите и используйте",
14 | "installation": "Установка",
15 | "select-device": "Выберите устройство",
16 | "pc": "ПК"
17 | },
18 | "ios": {
19 | "widget": {
20 | "install-and-open-app": "Установите и откройте {{appName}}"
21 | }
22 | },
23 | "android": {
24 | "widget": {
25 | "install-and-open-app": "Установите и откройте {{appName}}"
26 | }
27 | },
28 | "linux": {
29 | "widget": {
30 | "download-app": "Скачайте {{appName}}"
31 | }
32 | },
33 | "macos": {
34 | "widget": {
35 | "download-app": "Скачайте {{appName}}"
36 | }
37 | },
38 | "windows": {
39 | "widget": {
40 | "download-app": "Скачайте {{appName}}"
41 | }
42 | }
43 | },
44 | "subscription-link": {
45 | "widget": {
46 | "link-copied": "Ссылка скопирована",
47 | "link-copied-to-clipboard": "Ссылка скопирована в буфер обмена",
48 | "get-link": "Получить ссылку",
49 | "scan-qr-code": "Сканируйте QR-код выше в клиенте",
50 | "line-1": "Простое добавление подписки в любой клиент. Есть и другой вариант: скопируйте ссылку ниже и вставьте в клиент.",
51 | "copy-link": "Скопировать ссылку"
52 | }
53 | },
54 | "subscription-info": {
55 | "widget": {
56 | "name": "Имя пользователя",
57 | "status": "Статус",
58 | "active": "Активна",
59 | "inactive": "Неактивна",
60 | "expires": "Истекает",
61 | "at": "В",
62 | "bandwidth": "Трафик"
63 | }
64 | },
65 | "get-expiration-text": {
66 | "util": {
67 | "expires-in": "Истекает {{expiration}}",
68 | "expired": "Истекла {{expiration}}",
69 | "unknown": "Неизвестно",
70 | "indefinitely": "Бессрочно"
71 | }
72 | }
73 | }
--------------------------------------------------------------------------------
/frontend/public/locales/zh/main.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": {
3 | "page": {
4 | "component": {
5 | "podpiska": "订阅"
6 | }
7 | }
8 | },
9 | "installation-guide": {
10 | "widget": {
11 | "add-subscription": "添加订阅",
12 | "add-subscription-button": "添加订阅",
13 | "connect-and-use": "连接并使用",
14 | "installation": "安装",
15 | "select-device": "选择设备",
16 | "pc": "PC"
17 | },
18 | "ios": {
19 | "widget": {
20 | "install-and-open-app": "安装并打开 {{appName}}"
21 | }
22 | },
23 | "android": {
24 | "widget": {
25 | "install-and-open-app": "安装并打开 {{appName}}"
26 | }
27 | },
28 | "linux": {
29 | "widget": {
30 | "download-app": "下载 {{appName}}"
31 | }
32 | },
33 | "macos": {
34 | "widget": {
35 | "download-app": "下载 {{appName}}"
36 | }
37 | },
38 | "windows": {
39 | "widget": {
40 | "download-app": "下载 {{appName}}"
41 | }
42 | }
43 | },
44 | "subscription-link": {
45 | "widget": {
46 | "link-copied": "链接已复制",
47 | "link-copied-to-clipboard": "链接已复制到剪贴板",
48 | "get-link": "获取链接",
49 | "scan-qr-code": "在客户端中扫描上方二维码",
50 | "line-1": "轻松将订阅添加到任何客户端。还有另一种选择:复制下面的链接并粘贴到客户端中",
51 | "copy-link": "复制链接"
52 | }
53 | },
54 | "subscription-info": {
55 | "widget": {
56 | "name": "用户名",
57 | "status": "状态",
58 | "active": "活跃",
59 | "inactive": "未激活",
60 | "expires": "到期时间",
61 | "at": "于",
62 | "bandwidth": "流量"
63 | }
64 | },
65 | "get-expiration-text": {
66 | "util": {
67 | "expires-in": "{{expiration}} 后到期",
68 | "expired": "已于 {{expiration}} 过期",
69 | "unknown": "未知",
70 | "indefinitely": "永久"
71 | }
72 | }
73 | }
--------------------------------------------------------------------------------
/frontend/src/app.tsx:
--------------------------------------------------------------------------------
1 | import '@mantine/core/styles.layer.css'
2 | import '@mantine/dates/styles.layer.css'
3 | import '@mantine/notifications/styles.layer.css'
4 | import '@mantine/nprogress/styles.layer.css'
5 |
6 | import './global.css'
7 |
8 | import { Center, DirectionProvider, MantineProvider } from '@mantine/core'
9 | import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'
10 | import customParseFormat from 'dayjs/plugin/customParseFormat'
11 | import { NavigationProgress } from '@mantine/nprogress'
12 | import { Notifications } from '@mantine/notifications'
13 | import { ModalsProvider } from '@mantine/modals'
14 | import { useMediaQuery } from '@mantine/hooks'
15 | import { Suspense } from 'react'
16 | import dayjs from 'dayjs'
17 |
18 | import { LoadingScreen } from '@shared/ui/loading-screen'
19 | import { theme } from '@shared/constants'
20 |
21 | import { Router } from './app/router/router'
22 |
23 | dayjs.extend(customParseFormat)
24 |
25 | polyfillCountryFlagEmojis()
26 |
27 | export function App() {
28 | const mq = useMediaQuery('(min-width: 40em)')
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
39 |
40 |
41 | }
42 | >
43 |
44 |
45 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/frontend/src/app/i18n/i18n.ts:
--------------------------------------------------------------------------------
1 | import LanguageDetector from 'i18next-browser-languagedetector'
2 | import { initReactI18next } from 'react-i18next'
3 | import HttpApi from 'i18next-http-backend'
4 | import i18n from 'i18next'
5 |
6 | i18n.use(initReactI18next)
7 | .use(LanguageDetector)
8 | .use(HttpApi)
9 | .init({
10 | fallbackLng: 'en',
11 | debug: process.env.NODE_ENV === 'development',
12 | defaultNS: ['main'],
13 | ns: ['main'],
14 | detection: {
15 | order: ['localStorage', 'navigator', 'htmlTag', 'path', 'subdomain'],
16 | caches: ['localStorage']
17 | },
18 | load: 'languageOnly',
19 | preload: ['en', 'ru', 'fa', 'zh'],
20 | backend: {
21 | loadPath: '/locales/{{lng}}/{{ns}}.json'
22 | },
23 | interpolation: {
24 | escapeValue: false
25 | },
26 | react: {
27 | useSuspense: true
28 | }
29 | })
30 |
31 | export default i18n
32 |
--------------------------------------------------------------------------------
/frontend/src/app/layouts/root/root.layout.tsx:
--------------------------------------------------------------------------------
1 | import { GetSubscriptionInfoByShortUuidCommand } from '@remnawave/backend-contract'
2 | import { useEffect, useLayoutEffect, useState } from 'react'
3 | import { Outlet } from 'react-router-dom'
4 | import consola from 'consola/browser'
5 |
6 | import { useSubscriptionInfoStoreActions } from '@entities/subscription-info-store/subscription-info-store'
7 | import { LoadingScreen } from '@shared/ui/loading-screen/loading-screen'
8 |
9 | import classes from './root.module.css'
10 | import i18n from '../../i18n/i18n'
11 |
12 | export function RootLayout() {
13 | const actions = useSubscriptionInfoStoreActions()
14 | const [i18nInitialized, setI18nInitialized] = useState(i18n.isInitialized)
15 |
16 | useLayoutEffect(() => {
17 | const rootDiv = document.getElementById('root')
18 |
19 | if (rootDiv) {
20 | const subscriptionUrl = rootDiv.dataset.panel
21 |
22 | if (subscriptionUrl) {
23 | try {
24 | const subscription: GetSubscriptionInfoByShortUuidCommand.Response = JSON.parse(
25 | atob(subscriptionUrl)
26 | )
27 |
28 | actions.setSubscriptionInfo({
29 | subscription: subscription.response
30 | })
31 | } catch (error) {
32 | consola.log(error)
33 | } finally {
34 | delete rootDiv.dataset.panel
35 | }
36 | }
37 | }
38 | }, [])
39 |
40 | useEffect(() => {
41 | if (!i18nInitialized) {
42 | i18n.on('initialized', () => {
43 | setI18nInitialized(true)
44 | })
45 | }
46 | }, [i18nInitialized])
47 |
48 | if (!i18nInitialized) {
49 | return
50 | }
51 |
52 | return (
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/src/app/layouts/root/root.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | flex-grow: 1;
4 | min-height: 100vh;
5 | }
6 |
7 | .content {
8 | display: flex;
9 | flex-direction: column;
10 | width: 100%;
11 | }
12 |
13 | .main {
14 | max-width: 530px;
15 | width: 100%;
16 | align-self: center;
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/app/router/index.ts:
--------------------------------------------------------------------------------
1 | export * from './router'
2 |
--------------------------------------------------------------------------------
/frontend/src/app/router/router.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createBrowserRouter,
3 | createRoutesFromElements,
4 | Route,
5 | RouterProvider
6 | } from 'react-router-dom'
7 |
8 | import { ErrorPageComponent } from '@pages/errors/5xx-error/server-error.component'
9 | import { MainPageConnector } from '@pages/main/ui/connectors/main.page.connector'
10 | import { ErrorBoundaryHoc } from '@shared/hocs/error-boundary'
11 |
12 | import { RootLayout } from '../layouts/root/root.layout'
13 |
14 | const router = createBrowserRouter(
15 | createRoutesFromElements(
16 | } />}>
17 | } path="*">
18 | } path="*" />
19 |
20 |
21 | )
22 | )
23 |
24 | export function Router() {
25 | return
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/entities/subscription-info-store/index.ts:
--------------------------------------------------------------------------------
1 | export * from './interfaces'
2 | export * from './subscription-info-store'
3 |
--------------------------------------------------------------------------------
/frontend/src/entities/subscription-info-store/interfaces/action.interface.ts:
--------------------------------------------------------------------------------
1 | import { IState } from './state.interface'
2 |
3 | export interface IActions {
4 | actions: {
5 | getInitialState: () => IState
6 | resetState: () => Promise
7 | setSubscriptionInfo: (info: IState) => void
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/entities/subscription-info-store/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | export * from './action.interface'
2 | export * from './state.interface'
3 |
--------------------------------------------------------------------------------
/frontend/src/entities/subscription-info-store/interfaces/state.interface.ts:
--------------------------------------------------------------------------------
1 | import { GetSubscriptionInfoByShortUuidCommand } from '@remnawave/backend-contract'
2 |
3 | export interface IState {
4 | subscription: GetSubscriptionInfoByShortUuidCommand.Response['response'] | null
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/entities/subscription-info-store/subscription-info-store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | import { IActions, IState } from './interfaces'
4 |
5 | const initialState: IState = {
6 | subscription: null
7 | }
8 |
9 | export const useSubscriptionInfoStore = create()((set) => ({
10 | ...initialState,
11 | actions: {
12 | setSubscriptionInfo: (info: IState) => {
13 | set((state) => ({
14 | ...state,
15 | subscription: info.subscription
16 | }))
17 | },
18 | getInitialState: () => {
19 | return initialState
20 | },
21 | resetState: async () => {
22 | set({ ...initialState })
23 | }
24 | }
25 | }))
26 |
27 | export const useSubscriptionInfoStoreActions = () =>
28 | useSubscriptionInfoStore((store) => store.actions)
29 |
30 | export const useSubscriptionInfoStoreInfo = () => useSubscriptionInfoStore((state) => state)
31 |
--------------------------------------------------------------------------------
/frontend/src/global.css:
--------------------------------------------------------------------------------
1 | @layer mantine, mantine-datatable;
2 |
3 | html,
4 | body {
5 | min-height: 100vh;
6 | }
7 |
8 | #root {
9 | height: 100vh;
10 |
11 | @mixin dark {
12 | .mrt-table-head-sort-button {
13 | border-radius: 0;
14 | border: none;
15 | }
16 | .mrt-table-head-cell-filter-label-icon {
17 | border-radius: 0;
18 | border: none;
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 |
3 | import { App } from './app'
4 |
5 | const root = ReactDOM.createRoot(document.getElementById('root')!)
6 | root.render()
7 |
--------------------------------------------------------------------------------
/frontend/src/pages/errors/5xx-error/ServerError.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding-top: rem(80px);
3 | padding-bottom: rem(80px);
4 | height: 100vh;
5 | display: flex;
6 | align-items: center;
7 | justify-content: center;
8 | }
9 |
10 | .label {
11 | text-align: center;
12 | font-weight: 900;
13 | font-size: rem(220px);
14 | line-height: 1;
15 | margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
16 | color: var(--mantine-color-blue-3);
17 |
18 | @media (max-width: $mantine-breakpoint-sm) {
19 | font-size: rem(120px);
20 | }
21 | }
22 |
23 | .title {
24 | text-align: center;
25 | font-weight: 900;
26 | font-size: rem(38px);
27 | color: var(--mantine-color-white);
28 |
29 | @media (max-width: $mantine-breakpoint-sm) {
30 | font-size: rem(32px);
31 | }
32 | }
33 |
34 | .description {
35 | max-width: rem(540px);
36 | margin: auto;
37 | margin-top: var(--mantine-spacing-xl);
38 | margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
39 | color: var(--mantine-color-blue-1);
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/pages/errors/5xx-error/index.ts:
--------------------------------------------------------------------------------
1 | export * from './server-error.component'
2 |
--------------------------------------------------------------------------------
/frontend/src/pages/errors/5xx-error/server-error.component.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Container, Group, Text, Title } from '@mantine/core'
2 | import { useNavigate } from 'react-router-dom'
3 |
4 | import classes from './ServerError.module.css'
5 |
6 | export function ErrorPageComponent() {
7 | const navigate = useNavigate()
8 |
9 | const handleRefresh = () => {
10 | navigate(0)
11 | }
12 |
13 | return (
14 |
15 |
16 | 500
17 | Something bad just happened...
18 |
19 | Try to refresh the page.
20 |
21 |
22 |
25 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/pages/main/ui/components/main.page.component.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Container, Group, Image, Stack, Title } from '@mantine/core'
2 |
3 | import {
4 | ISubscriptionPageAppConfig,
5 | TEnabledLocales
6 | } from '@shared/constants/apps-config/interfaces/app-list.interface'
7 | import { LanguagePicker } from '@shared/ui/language-picker/language-picker.shared'
8 |
9 | import { InstallationGuideWidget } from '../../../../widgets/main/installation-guide/installation-guide.widget'
10 | import { SubscriptionLinkWidget } from '../../../../widgets/main/subscription-link/subscription-link.widget'
11 | import { SubscriptionInfoWidget } from '../../../../widgets/main/subscription-info/subscription-info.widget'
12 |
13 | export const MainPageComponent = ({
14 | subscriptionPageAppConfig
15 | }: {
16 | subscriptionPageAppConfig: ISubscriptionPageAppConfig
17 | }) => {
18 | let additionalLocales: TEnabledLocales[] = ['en', 'ru', 'fa', 'zh']
19 |
20 | if (subscriptionPageAppConfig.config.additionalLocales !== undefined) {
21 | additionalLocales = [
22 | 'en',
23 | ...subscriptionPageAppConfig.config.additionalLocales.filter((locale) =>
24 | ['fa', 'ru', 'zh'].includes(locale)
25 | )
26 | ]
27 | }
28 |
29 | return (
30 |
31 |
32 |
33 |
39 | {subscriptionPageAppConfig.config.branding?.logoUrl && (
40 |
51 | )}
52 |
53 |
54 | {subscriptionPageAppConfig.config.branding?.name || 'Subscription'}
55 |
56 |
57 |
58 |
59 |
62 |
63 |
64 |
65 |
66 |
67 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/frontend/src/pages/main/ui/connectors/main.page.connector.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import consola from 'consola/browser'
3 | import { ofetch } from 'ofetch'
4 |
5 | import { useSubscriptionInfoStoreInfo } from '@entities/subscription-info-store'
6 | import { ISubscriptionPageAppConfig } from '@shared/constants/apps-config'
7 | import { isOldFormat } from '@shared/utils/migration.utils'
8 | import { LoadingScreen } from '@shared/ui'
9 |
10 | import { MainPageComponent } from '../components/main.page.component'
11 |
12 | export const MainPageConnector = () => {
13 | const { subscription } = useSubscriptionInfoStoreInfo()
14 | const [appsConfig, setAppsConfig] = useState(null)
15 | const [isLoading, setIsLoading] = useState(true)
16 |
17 | useEffect(() => {
18 | const fetchConfig = async () => {
19 | try {
20 | const tempConfig = await ofetch(
21 | `/assets/app-config.json?v=${Date.now()}`,
22 | {
23 | parseResponse: JSON.parse
24 | }
25 | )
26 |
27 | let newConfig: ISubscriptionPageAppConfig | null = null
28 |
29 | if (isOldFormat(tempConfig)) {
30 | consola.warn('Old config format detected, migrating to new format...')
31 | newConfig = {
32 | config: {
33 | additionalLocales: ['ru', 'fa', 'zh']
34 | },
35 | platforms: {
36 | ios: tempConfig.ios,
37 | android: tempConfig.android,
38 | windows: tempConfig.pc,
39 | macos: tempConfig.pc,
40 | linux: [],
41 | androidTV: [],
42 | appleTV: []
43 | }
44 | }
45 | } else {
46 | newConfig = tempConfig
47 | }
48 |
49 | setAppsConfig(newConfig)
50 | } catch (error) {
51 | consola.error('Failed to fetch app config:', error)
52 | } finally {
53 | setIsLoading(false)
54 | }
55 | }
56 |
57 | fetchConfig()
58 | }, [])
59 |
60 | if (isLoading || !subscription || !appsConfig) return
61 |
62 | return
63 | }
64 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/apps-config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './interfaces'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/apps-config/interfaces/app-list.interface.ts:
--------------------------------------------------------------------------------
1 | export type TAdditionalLocales = 'fa' | 'ru' | 'zh'
2 | export type TEnabledLocales = 'en' | TAdditionalLocales
3 | export type TPlatform = 'android' | 'androidTV' | 'appleTV' | 'ios' | 'linux' | 'macos' | 'windows'
4 |
5 | export interface ILocalizedText {
6 | en: string
7 | fa?: string
8 | ru?: string
9 | zh?: string
10 | }
11 |
12 | export interface IStep {
13 | description: ILocalizedText
14 | }
15 |
16 | export interface IButton {
17 | buttonLink: string
18 | buttonText: ILocalizedText
19 | }
20 | export interface ITitleStep extends IStep {
21 | buttons: IButton[]
22 | title: ILocalizedText
23 | }
24 |
25 | export interface IAppConfig {
26 | additionalAfterAddSubscriptionStep?: ITitleStep
27 | additionalBeforeAddSubscriptionStep?: ITitleStep
28 | addSubscriptionStep: IStep
29 | connectAndUseStep: IStep
30 | id: string
31 | installationStep: {
32 | buttons: IButton[]
33 | description: ILocalizedText
34 | }
35 | isFeatured: boolean
36 | isNeedBase64Encoding?: boolean
37 | name: string
38 | urlScheme: string
39 | }
40 |
41 | export interface ISubscriptionPageConfiguration {
42 | additionalLocales: TAdditionalLocales[]
43 | branding?: {
44 | logoUrl?: string
45 | name?: string
46 | supportUrl?: string
47 | }
48 | }
49 |
50 | export interface ISubscriptionPageAppConfig {
51 | config: ISubscriptionPageConfiguration
52 | platforms: Record
53 | }
54 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/apps-config/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | export * from './app-list.interface'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './apps-config'
2 | export * from './theme'
3 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/index.ts:
--------------------------------------------------------------------------------
1 | export * from './theme'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/badge.ts:
--------------------------------------------------------------------------------
1 | import { Badge } from '@mantine/core'
2 |
3 | export default {
4 | Badge: Badge.extend({
5 | defaultProps: {
6 | radius: 'md',
7 | variant: 'outline'
8 | }
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/breadcrumbs.tsx:
--------------------------------------------------------------------------------
1 | import { GoDotFill as BreadcrumbsSeparator } from 'react-icons/go'
2 | import { Breadcrumbs } from '@mantine/core'
3 |
4 | export default {
5 | Breadcrumbs: Breadcrumbs.extend({
6 | defaultProps: {
7 | separator: (
8 |
13 | )
14 | }
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/buttons.ts:
--------------------------------------------------------------------------------
1 | import { ActionIcon, Button } from '@mantine/core'
2 |
3 | export default {
4 | ActionIcon: ActionIcon.extend({
5 | defaultProps: {
6 | radius: 'lg',
7 | variant: 'outline'
8 | }
9 | }),
10 | Button: Button.extend({
11 | defaultProps: {
12 | radius: 'lg',
13 | variant: 'outline'
14 | }
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/card/card.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | background-color: var(--mantine-color-body);
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/card/index.ts:
--------------------------------------------------------------------------------
1 | import { Card } from '@mantine/core'
2 |
3 | import classes from './card.module.css'
4 |
5 | export default {
6 | Card: Card.extend({
7 | classNames: classes,
8 | defaultProps: {
9 | radius: 'md',
10 | withBorder: true
11 | }
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/drawer.ts:
--------------------------------------------------------------------------------
1 | import { DrawerOverlay } from '@mantine/core'
2 |
3 | export default {
4 | DrawerOverlay: DrawerOverlay.extend({
5 | defaultProps: {
6 | backgroundOpacity: 0.5,
7 | blur: 2
8 | }
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/index.ts:
--------------------------------------------------------------------------------
1 | import loadingOverlay from './loading-overlay'
2 | import ringProgress from './ring-progress'
3 | import notification from './notification'
4 | import breadcrumbs from './breadcrumbs'
5 | import buttons from './buttons'
6 | import layouts from './layouts'
7 | import tooltip from './tooltip'
8 | import drawer from './drawer'
9 | import inputs from './inputs'
10 | import badge from './badge'
11 | import table from './table'
12 | import card from './card'
13 | import menu from './menu'
14 |
15 | export default {
16 | ...card,
17 | ...badge,
18 | ...breadcrumbs,
19 | ...buttons,
20 | ...drawer,
21 | ...inputs,
22 | ...loadingOverlay,
23 | ...menu,
24 | ...notification,
25 | ...ringProgress,
26 | ...table,
27 | ...tooltip,
28 | ...layouts
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/inputs.ts:
--------------------------------------------------------------------------------
1 | import { InputBase, PasswordInput, Select, TextInput } from '@mantine/core'
2 | import { DateTimePicker } from '@mantine/dates'
3 |
4 | export default {
5 | InputBase: InputBase.extend({
6 | defaultProps: {
7 | radius: 'md'
8 | }
9 | }),
10 | PasswordInput: PasswordInput.extend({
11 | defaultProps: {
12 | radius: 'md'
13 | }
14 | }),
15 | TextInput: TextInput.extend({
16 | defaultProps: {
17 | radius: 'md'
18 | }
19 | }),
20 | Select: Select.extend({
21 | defaultProps: {
22 | radius: 'md'
23 | }
24 | }),
25 | DateTimePicker: DateTimePicker.extend({
26 | defaultProps: {
27 | radius: 'md'
28 | }
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/layouts.ts:
--------------------------------------------------------------------------------
1 | import { Paper } from '@mantine/core'
2 |
3 | export default {
4 | Paper: Paper.extend({
5 | defaultProps: {
6 | radius: 'lg'
7 | }
8 | })
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/loading-overlay.ts:
--------------------------------------------------------------------------------
1 | import { LoadingOverlay } from '@mantine/core'
2 |
3 | export default {
4 | LoadingOverlay: LoadingOverlay.extend({
5 | defaultProps: {
6 | zIndex: 1000,
7 | overlayProps: {
8 | radius: 'sm',
9 | blur: 4
10 | }
11 | }
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/menu.ts:
--------------------------------------------------------------------------------
1 | import { Combobox, Menu } from '@mantine/core'
2 |
3 | export default {
4 | Menu: Menu.extend({
5 | defaultProps: {
6 | shadow: 'md',
7 | withArrow: true,
8 | transitionProps: { transition: 'scale', duration: 200 }
9 | }
10 | }),
11 | Combobox: Combobox.extend({
12 | defaultProps: {
13 | shadow: 'md',
14 | withArrow: false,
15 | transitionProps: { transition: 'scale', duration: 200 }
16 | }
17 | })
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/notification.ts:
--------------------------------------------------------------------------------
1 | import { Notification } from '@mantine/core'
2 |
3 | export default {
4 | Notification: Notification.extend({
5 | defaultProps: {
6 | radius: 'md'
7 | }
8 | })
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/ring-progress.ts:
--------------------------------------------------------------------------------
1 | import { RingProgress } from '@mantine/core'
2 |
3 | export default {
4 | RingProgress: RingProgress.extend({
5 | defaultProps: {
6 | thickness: 6,
7 | roundCaps: true
8 | }
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/table.ts:
--------------------------------------------------------------------------------
1 | import { Table } from '@mantine/core'
2 |
3 | export default {
4 | Table: Table.extend({
5 | defaultProps: {
6 | highlightOnHover: true
7 | }
8 | })
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/overrides/tooltip.ts:
--------------------------------------------------------------------------------
1 | import { Tooltip } from '@mantine/core'
2 |
3 | export default {
4 | Tooltip: Tooltip.extend({
5 | defaultProps: {
6 | radius: 'md',
7 | withArrow: true,
8 | transitionProps: { transition: 'scale-x', duration: 300 },
9 | arrowSize: 2,
10 | color: 'gray'
11 | }
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/shared/constants/theme/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@mantine/core'
2 |
3 | import components from './overrides'
4 |
5 | export const theme = createTheme({
6 | components,
7 | cursorType: 'pointer',
8 | fontFamily: 'Montserrat, Twemoji Country Flags, sans-serif',
9 | fontFamilyMonospace: 'Fira Mono, monospace',
10 | breakpoints: {
11 | xs: '25em',
12 | sm: '30em',
13 | md: '48em',
14 | lg: '64em',
15 | xl: '80em',
16 | '2xl': '96em',
17 | '3xl': '120em',
18 | '4xl': '160em'
19 | },
20 | scale: 1,
21 | fontSmoothing: true,
22 | focusRing: 'never',
23 | white: '#ffffff',
24 | black: '#24292f',
25 | colors: {
26 | dark: [
27 | '#c9d1d9',
28 | '#b1bac4',
29 | '#8b949e',
30 | '#6e7681',
31 | '#484f58',
32 | '#30363d',
33 | '#21262d',
34 | '#161b22',
35 | '#0d1117',
36 | '#010409'
37 | ],
38 |
39 | blue: [
40 | '#ddf4ff',
41 | '#b6e3ff',
42 | '#80ccff',
43 | '#54aeff',
44 | '#218bff',
45 | '#0969da',
46 | '#0550ae',
47 | '#033d8b',
48 | '#0a3069',
49 | '#002155'
50 | ],
51 | green: [
52 | '#dafbe1',
53 | '#aceebb',
54 | '#6fdd8b',
55 | '#4ac26b',
56 | '#2da44e',
57 | '#1a7f37',
58 | '#116329',
59 | '#044f1e',
60 | '#003d16',
61 | '#002d11'
62 | ],
63 | yellow: [
64 | '#fff8c5',
65 | '#fae17d',
66 | '#eac54f',
67 | '#d4a72c',
68 | '#bf8700',
69 | '#9a6700',
70 | '#7d4e00',
71 | '#633c01',
72 | '#4d2d00',
73 | '#3b2300'
74 | ],
75 | orange: [
76 | '#fff1e5',
77 | '#ffd8b5',
78 | '#ffb77c',
79 | '#fb8f44',
80 | '#e16f24',
81 | '#bc4c00',
82 | '#953800',
83 | '#762c00',
84 | '#5c2200',
85 | '#471700'
86 | ]
87 | },
88 | primaryShade: 8,
89 | primaryColor: 'cyan',
90 | autoContrast: true,
91 | luminanceThreshold: 0.3,
92 | headings: {
93 | fontWeight: '600'
94 | },
95 | defaultRadius: 'md'
96 | })
97 |
--------------------------------------------------------------------------------
/frontend/src/shared/hocs/error-boundary/error-boundary-hoc.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundary, ErrorBoundaryProps } from 'react-error-boundary'
2 | import { Outlet } from 'react-router-dom'
3 | import { FC } from 'react'
4 |
5 | export const ErrorBoundaryHoc: FC = (props) => {
6 | return (
7 |
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/shared/hocs/error-boundary/index.ts:
--------------------------------------------------------------------------------
1 | export * from './error-boundary-hoc'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/index.ts:
--------------------------------------------------------------------------------
1 | export * from './loading-screen'
2 | export * from './logo'
3 | export * from './page'
4 | export * from './page-header'
5 | export * from './underline-shape'
6 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/info-block/info-block.shared.tsx:
--------------------------------------------------------------------------------
1 | import { Group, Paper, Text, ThemeIcon } from '@mantine/core'
2 |
3 | import { IInfoBlockProps } from './interfaces/props.interface'
4 |
5 | export const InfoBlockShared = (props: IInfoBlockProps) => {
6 | const { color, icon, title, value } = props
7 |
8 | return (
9 |
10 |
11 |
12 | {icon}
13 |
14 |
15 | {title}
16 |
17 |
18 |
19 | {value}
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/info-block/interfaces/props.interface.tsx:
--------------------------------------------------------------------------------
1 | export interface IInfoBlockProps {
2 | color: string
3 | icon: React.ReactNode
4 | title: string
5 | value: string
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/language-picker/LanguagePicker.module.css:
--------------------------------------------------------------------------------
1 | .control {
2 | width: 150px;
3 | display: flex;
4 | justify-content: space-between;
5 | align-items: center;
6 | padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
7 | border-radius: var(--mantine-radius-md);
8 | border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
9 | transition: background-color 150ms ease;
10 | background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
11 |
12 | &[data-expanded] {
13 | background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
14 | }
15 |
16 | @mixin hover {
17 | background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
18 | }
19 | }
20 |
21 | .label {
22 | font-weight: 500;
23 | font-size: var(--mantine-font-size-sm);
24 | }
25 |
26 | .icon {
27 | transition: transform 150ms ease;
28 | transform: rotate(0deg);
29 |
30 | [data-expanded] & {
31 | transform: rotate(180deg);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/language-picker/language-picker.shared.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Group, Menu, Text, useDirection } from '@mantine/core'
2 | import { IconChevronDown } from '@tabler/icons-react'
3 | import { useTranslation } from 'react-i18next'
4 | import { useEffect, useState } from 'react'
5 |
6 | import { TEnabledLocales } from '@shared/constants/apps-config/interfaces/app-list.interface'
7 |
8 | import classes from './LanguagePicker.module.css'
9 |
10 | const data = [
11 | { label: 'English', emoji: '🇺🇸', value: 'en' },
12 | { label: 'Русский', emoji: '🇷🇺', value: 'ru' },
13 | { label: 'فارسی', emoji: '🇮🇷', value: 'fa' },
14 | { label: '简体中文', emoji: '🇨🇳', value: 'zh' }
15 | ]
16 |
17 | export function LanguagePicker({ enabledLocales }: { enabledLocales: TEnabledLocales[] }) {
18 | const [opened, setOpened] = useState(false)
19 | const [selectedLanguage, setSelectedLanguage] = useState('en')
20 | const { toggleDirection, dir } = useDirection()
21 |
22 | const filteredData = data.filter((item) =>
23 | enabledLocales.includes(item.value as TEnabledLocales)
24 | )
25 |
26 | const { i18n } = useTranslation()
27 |
28 | useEffect(() => {
29 | const savedLanguage = i18n.language
30 |
31 | if (savedLanguage) {
32 | if (savedLanguage === 'fa') {
33 | if (dir === 'ltr') {
34 | toggleDirection()
35 | }
36 | }
37 | }
38 | }, [i18n])
39 |
40 | useEffect(() => {
41 | setSelectedLanguage(i18n.language)
42 | }, [i18n])
43 |
44 | const changeLanguage = (value: string) => {
45 | i18n.changeLanguage(value)
46 |
47 | if (value === 'fa' && dir === 'ltr') {
48 | toggleDirection()
49 | }
50 |
51 | if (dir === 'rtl' && value !== 'fa') {
52 | toggleDirection()
53 | }
54 |
55 | setSelectedLanguage(value)
56 | }
57 |
58 | const selected =
59 | filteredData.find((item) => selectedLanguage.startsWith(item.value)) || filteredData[0]
60 |
61 | const items = filteredData.map((item) => (
62 | {item.emoji}}
65 | onClick={() => changeLanguage(item.value)}
66 | >
67 | {item.label}
68 |
69 | ))
70 |
71 | return (
72 |
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/loader-modal/index.tsx:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line react-refresh/only-export-components
2 | export * from './loader-model.shared'
3 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/loader-modal/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | export * from './props.interface'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/loader-modal/interfaces/props.interface.ts:
--------------------------------------------------------------------------------
1 | import type { CenterProps, ElementProps } from '@mantine/core'
2 |
3 | export interface IProps extends CenterProps, ElementProps<'div', keyof CenterProps> {
4 | text: string
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/loader-modal/loader-model.shared.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Loader, Stack, Text } from '@mantine/core'
2 |
3 | import { IProps } from './interfaces'
4 |
5 | export function LoaderModalShared(props: IProps) {
6 | const { text, ...rest } = props
7 |
8 | return (
9 |
10 |
11 |
12 |
13 | {text}
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/loading-screen/index.ts:
--------------------------------------------------------------------------------
1 | export * from './loading-screen'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/loading-screen/loading-screen.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Progress, Stack, Text } from '@mantine/core'
2 |
3 | export function LoadingScreen({
4 | height = '100%',
5 | text = undefined,
6 | value = 100
7 | }: {
8 | height?: string
9 | text?: string
10 | value?: number
11 | }) {
12 | return (
13 |
14 |
15 | {text && {text}}
16 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/logo.tsx:
--------------------------------------------------------------------------------
1 | import { Box, BoxProps, ElementProps } from '@mantine/core'
2 |
3 | interface LogoProps
4 | extends ElementProps<'svg', keyof BoxProps>,
5 | Omit {
6 | size?: number | string
7 | }
8 |
9 | export function Logo({ size, style, ...props }: LogoProps) {
10 | return (
11 |
19 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/page-header.tsx:
--------------------------------------------------------------------------------
1 | import { Anchor, Breadcrumbs, ElementProps, Group, GroupProps, Text, Title } from '@mantine/core'
2 | import { NavLink } from 'react-router-dom'
3 | import { ReactNode } from 'react'
4 |
5 | interface PageHeaderProps
6 | extends ElementProps<'header', keyof GroupProps>,
7 | Omit {
8 | breadcrumbs?: { href?: string; label: string }[]
9 | title: ReactNode
10 | }
11 |
12 | export function PageHeader({
13 | children,
14 | title,
15 | breadcrumbs,
16 | className,
17 | mb = 'xl',
18 | ...props
19 | }: PageHeaderProps) {
20 | return (
21 |
22 |
23 |
24 | {title}
25 |
26 |
27 | {breadcrumbs && (
28 |
29 | {breadcrumbs.map((breadcrumb) =>
30 | breadcrumb.href ? (
31 |
39 | {breadcrumb.label}
40 |
41 | ) : (
42 |
43 | {breadcrumb.label}
44 |
45 | )
46 | )}
47 |
48 | )}
49 |
50 |
51 | {children}
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/page/index.ts:
--------------------------------------------------------------------------------
1 | export * from './page'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/page/page.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, ReactNode, useEffect } from 'react'
2 | import { AnimatePresence, motion } from 'framer-motion'
3 | import { nprogress } from '@mantine/nprogress'
4 | import { Box, BoxProps } from '@mantine/core'
5 |
6 | interface PageProps extends BoxProps {
7 | children: ReactNode
8 | meta?: ReactNode
9 | title: string
10 | }
11 |
12 | export const Page = forwardRef(
13 | ({ children, title = '', meta, ...other }, ref) => {
14 | useEffect(() => {
15 | nprogress.complete()
16 | return () => nprogress.start()
17 | }, [])
18 |
19 | return (
20 | <>
21 | {`${title} | Subscription`}
22 | {meta}
23 |
24 |
25 |
34 |
35 | {children}
36 |
37 |
38 |
39 | >
40 | )
41 | }
42 | )
43 |
--------------------------------------------------------------------------------
/frontend/src/shared/ui/underline-shape.tsx:
--------------------------------------------------------------------------------
1 | import { Box, BoxProps, ElementProps } from '@mantine/core'
2 |
3 | interface UnderlineShape
4 | extends ElementProps<'svg', keyof BoxProps>,
5 | Omit {
6 | size?: number | string
7 | }
8 |
9 | export function UnderlineShape({ size, style, ...props }: UnderlineShape) {
10 | return (
11 |
19 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/bytes/bytes-to-gb/bytes-to-gb.util.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | import xbytes from 'xbytes'
3 |
4 | export function bytesToGbUtil(bytesInput: number | string | undefined): number | undefined {
5 | if (typeof bytesInput === 'undefined') return undefined
6 |
7 | if (typeof bytesInput === 'string') {
8 | bytesInput = Number(bytesInput)
9 | }
10 |
11 | const res = xbytes.parseBytes(bytesInput, {
12 | sticky: true,
13 | prefixIndex: 3,
14 | fixed: 0,
15 | iec: true,
16 | space: false
17 | })
18 |
19 | return Number(res.size.replace('GiB', ''))
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/bytes/bytes-to-gb/index.ts:
--------------------------------------------------------------------------------
1 | export * from './bytes-to-gb.util'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/bytes/gb-to-bytes/gb-to-bytes.util.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | import xbytes from 'xbytes'
3 |
4 | export function gbToBytesUtil(gbInput: number | undefined): number | undefined {
5 | if (typeof gbInput === 'undefined') return undefined
6 | if (typeof gbInput === 'string') {
7 | gbInput = Number(gbInput)
8 | }
9 |
10 | const res = xbytes.parse(`${gbInput} GiB`)
11 |
12 | return res.bytes
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/bytes/gb-to-bytes/index.ts:
--------------------------------------------------------------------------------
1 | export * from './gb-to-bytes.util'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/bytes/index.ts:
--------------------------------------------------------------------------------
1 | export * from './bytes-to-gb'
2 | export * from './gb-to-bytes'
3 | export * from './pretty-bytes'
4 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/bytes/pretty-bytes/index.ts:
--------------------------------------------------------------------------------
1 | export * from './pretty-bytes.util'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/bytes/pretty-bytes/pretty-bytes.util.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | import xbytes from 'xbytes'
3 |
4 | export function prettyBytesToAnyUtil(
5 | bytesInput: number | string | undefined,
6 | returnZero: boolean = false
7 | ): string | undefined {
8 | if (!bytesInput) {
9 | return returnZero ? '0' : undefined
10 | }
11 | if (typeof bytesInput === 'string') {
12 | bytesInput = Number(bytesInput)
13 | }
14 |
15 | const res = xbytes.parseBytes(bytesInput, { sticky: true, iec: true })
16 |
17 | return String(res.size)
18 | }
19 |
20 | export function prettyBytesUtil(
21 | bytesInput: number | string | undefined,
22 | returnZero: boolean = false
23 | ): string | undefined {
24 | if (!bytesInput) {
25 | return returnZero ? '0' : undefined
26 | }
27 | if (typeof bytesInput === 'string') {
28 | bytesInput = Number(bytesInput)
29 | }
30 |
31 | const res = xbytes.parseBytes(bytesInput, { sticky: true, prefixIndex: 3, iec: true })
32 |
33 | return String(res.size)
34 | }
35 |
36 | export function prettyBytesUtilWithoutPrefix(
37 | bytesInput: number | string | undefined,
38 | returnZero: boolean = false
39 | ): string | undefined {
40 | if (!bytesInput) {
41 | return returnZero ? '0' : undefined
42 | }
43 | if (typeof bytesInput === 'string') {
44 | bytesInput = Number(bytesInput)
45 | }
46 |
47 | const res = xbytes.parseBytes(bytesInput, { sticky: true, iec: true })
48 |
49 | return String(res.size)
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/construct-subscription-url/construct-subscription-url.tsx:
--------------------------------------------------------------------------------
1 | import { joinURL, parseURL, stringifyParsedURL } from 'ufo'
2 |
3 | export const constructSubscriptionUrl = (currentUrl: string, shortUuid: string): string => {
4 | const url = parseURL(currentUrl)
5 |
6 | url.search = ''
7 | url.hash = ''
8 | url.auth = ''
9 |
10 | const segments = url.pathname.split('/').filter(Boolean)
11 | const lastSegment = segments.at(-1)
12 |
13 | if (lastSegment !== shortUuid) {
14 | segments.pop()
15 | segments.push(shortUuid)
16 | url.pathname = joinURL('/', ...segments)
17 | }
18 |
19 | return stringifyParsedURL(url)
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/construct-subscription-url/index.ts:
--------------------------------------------------------------------------------
1 | export * from './construct-subscription-url'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/fetch-with-progress/fetch-with-progress.util.ts:
--------------------------------------------------------------------------------
1 | import { consola } from 'consola/browser'
2 | import axios from 'axios'
3 |
4 | export const fetchWithProgress = async (url: string, onProgress?: (progress: number) => void) => {
5 | try {
6 | const response = await axios.get(url, {
7 | onDownloadProgress: (progressEvent) => {
8 | if (progressEvent.total) {
9 | const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
10 | onProgress?.(progress)
11 | } else {
12 | onProgress?.(100)
13 | }
14 | },
15 | responseType: 'arraybuffer'
16 | })
17 |
18 | return response.data
19 | } catch (error) {
20 | consola.error('Download failed:', error)
21 | throw error
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/fetch-with-progress/index.ts:
--------------------------------------------------------------------------------
1 | export * from './fetch-with-progress.util'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/migration.utils.ts:
--------------------------------------------------------------------------------
1 | interface LegacyPlatformConfig {
2 | ios: []
3 | android: []
4 | pc: []
5 | }
6 |
7 | export const isOldFormat = (config: unknown): config is LegacyPlatformConfig => {
8 | if (!config || typeof config !== 'object' || config === null) {
9 | return false
10 | }
11 |
12 | const configObj = config as Record
13 |
14 | return (
15 | Array.isArray(configObj.ios) &&
16 | Array.isArray(configObj.android) &&
17 | Array.isArray(configObj.pc) &&
18 | !configObj.config &&
19 | !configObj.platforms
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/boolean.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const booleanSchema = z
4 | .union([z.boolean(), z.literal('true'), z.literal('false')])
5 | .transform((value) => value === true || value === 'true')
6 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/date.ts:
--------------------------------------------------------------------------------
1 | import customParseFormat from 'dayjs/plugin/customParseFormat'
2 | import relativeTime from 'dayjs/plugin/relativeTime'
3 | import dayjs, { type Dayjs, isDayjs } from 'dayjs'
4 | import { z } from 'zod'
5 |
6 | dayjs.extend(customParseFormat)
7 | dayjs.extend(relativeTime)
8 |
9 | export type CustomDate = Dayjs
10 |
11 | export const date = dayjs
12 |
13 | export function formatDate(value: CustomDate | Date | string, format = 'DD-MM-YYYY') {
14 | return date(value).format(format)
15 | }
16 |
17 | export function formatRelativeDate(value: CustomDate | Date | string) {
18 | return date(value).fromNow()
19 | }
20 |
21 | /** Validate and transform date string to dayjs instance */
22 | export const dateSchema = z.custom((value) => {
23 | if (
24 | value instanceof Date ||
25 | isDayjs(value) ||
26 | (typeof value === 'string' && date(value).isValid())
27 | ) {
28 | return date(value)
29 | }
30 |
31 | throw new Error('Invalid date format')
32 | })
33 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/factory.ts:
--------------------------------------------------------------------------------
1 | export function generateMany(count: number, factory: (index: number) => T): T[] {
2 | return Array.from({ length: count }, (_, index) => factory(index))
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/format.ts:
--------------------------------------------------------------------------------
1 | export function formatBytes(bytes: number): string {
2 | const units = ['B', 'KB', 'MB', 'GB', 'TB']
3 | let value = bytes
4 | let unitIndex = 0
5 |
6 | while (value >= 1024 && unitIndex < units.length - 1) {
7 | value /= 1024
8 | unitIndex++
9 | }
10 |
11 | return `${value.toFixed(2)} ${units[unitIndex]}`
12 | }
13 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/index.ts:
--------------------------------------------------------------------------------
1 | export * from './boolean'
2 | export * from './date'
3 | export * from './factory'
4 | export * from './is'
5 | export * from './match'
6 | export * from './number'
7 | export * from './text'
8 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/is.ts:
--------------------------------------------------------------------------------
1 | export function isDefined(value: unknown): value is NonNullable {
2 | return value !== null && value !== undefined && value !== ''
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/match.ts:
--------------------------------------------------------------------------------
1 | import invariant from 'tiny-invariant'
2 |
3 | /**
4 | * PHP like match function
5 | * @param conditions - Array of [condition, value]
6 | * @returns Value of first met condition
7 | * @example
8 | * ```tsx
9 | * const value = match(
10 | * [condition1, value1],
11 | * [condition2, value2],
12 | * [condition3, value3],
13 | * [true, defaultValue]
14 | * );
15 | */
16 | export function match(...conditions: Array<[boolean, T]>) {
17 | const foundedCondition = conditions.find(([condition]) => condition) ?? conditions.at(-1)
18 | invariant(foundedCondition, 'No conditions have been met')
19 | return foundedCondition[1]
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/number.ts:
--------------------------------------------------------------------------------
1 | interface FormatterOptions {
2 | decimalSeparator?: string
3 | full?: boolean
4 | precision?: number
5 | prefix?: string
6 | suffix?: string
7 | thousandSeparator?: string
8 | }
9 |
10 | const defaultOptions: Required = {
11 | precision: 2,
12 | thousandSeparator: ',',
13 | decimalSeparator: '.',
14 | suffix: '',
15 | prefix: '',
16 | full: true
17 | }
18 |
19 | export function formatCurrency(
20 | value: number | string,
21 | options?: FormatterOptions,
22 | currency = '$'
23 | ): string {
24 | // eslint-disable-next-line no-use-before-define
25 | return formatDecimal(value, { ...options, prefix: currency })
26 | }
27 |
28 | export function formatDecimal(value: number | string, options?: FormatterOptions): string {
29 | const currentValue = typeof value === 'string' ? parseFloat(value) : value
30 |
31 | if (Number.isNaN(currentValue)) {
32 | throw new Error(
33 | 'Invalid value. Please provide a valid number or string representation of a number.'
34 | )
35 | }
36 |
37 | const { precision, thousandSeparator, decimalSeparator, prefix, suffix } = {
38 | ...defaultOptions,
39 | ...options
40 | }
41 |
42 | const parts = currentValue.toFixed(precision).split('.')
43 | const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator)
44 | const decimalPart = parts[1]
45 |
46 | return `${prefix}${integerPart}`
47 | .concat(decimalPart ? `${decimalSeparator}${decimalPart}` : '')
48 | .concat(suffix)
49 | }
50 |
51 | export function formatInt(value: number | string, options?: FormatterOptions) {
52 | return formatDecimal(value, { ...options, precision: 0 })
53 | }
54 |
55 | export function formatPercentage(value: number | string, options?: FormatterOptions): string {
56 | return formatDecimal(value, { ...options, suffix: '%' })
57 | }
58 |
59 | export function isNumber(value: unknown): value is number {
60 | return typeof value === 'number' && !Number.isNaN(value)
61 | }
62 |
63 | export function randomInt({ min, max }: { max: number; min: number }) {
64 | return Math.floor(Math.random() * (max - min + 1)) + min
65 | }
66 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/misc/text.ts:
--------------------------------------------------------------------------------
1 | export function capitalize(str: string) {
2 | return str.charAt(0).toUpperCase() + str.slice(1)
3 | }
4 |
5 | export function firstLetters(text: string) {
6 | return text
7 | .split(' ')
8 | .map((word) => word[0])
9 | .join('')
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/time-utils/get-expiration-text/get-expiration-text.util.ts:
--------------------------------------------------------------------------------
1 | import { i18n, TFunction } from 'i18next'
2 | import dayjs from 'dayjs'
3 | import 'dayjs/locale/ru'
4 | import 'dayjs/locale/fa'
5 |
6 | export function getExpirationTextUtil(
7 | expireAt: Date | null | string,
8 | t: TFunction,
9 | i18nProps: i18n
10 | ): string {
11 | if (!expireAt) {
12 | return 'Unknown'
13 | }
14 |
15 | const expiration = dayjs(expireAt).locale(i18nProps.language)
16 | const now = dayjs()
17 |
18 | if (expiration.isBefore(now)) {
19 | return t('get-expiration-text.util.expired', {
20 | expiration: expiration.fromNow(false)
21 | })
22 | }
23 |
24 | if (expiration.year() === 2099) {
25 | return t('get-expiration-text.util.indefinitely')
26 | }
27 |
28 | return t('get-expiration-text.util.expires-in', {
29 | expiration: expiration.fromNow(false)
30 | })
31 | }
32 |
33 | export const formatDate = (dateStr: Date | string, t: TFunction, i18nProps: i18n) => {
34 | if (dayjs(dateStr).year() === 2099) {
35 | return t('get-expiration-text.util.indefinitely')
36 | }
37 | return dayjs(dateStr).locale(i18nProps.language).format('DD.MM.YYYY')
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/time-utils/index.ts:
--------------------------------------------------------------------------------
1 | import relativeTime from 'dayjs/plugin/relativeTime'
2 | import duration from 'dayjs/plugin/duration'
3 | import timezone from 'dayjs/plugin/timezone'
4 | import utc from 'dayjs/plugin/utc'
5 | import dayjs from 'dayjs'
6 |
7 | dayjs.extend(relativeTime)
8 | dayjs.extend(utc)
9 | dayjs.extend(duration)
10 | dayjs.extend(timezone)
11 |
12 | export * from './s-to-ms/s-to-ms.util'
13 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/time-utils/s-to-ms/index.ts:
--------------------------------------------------------------------------------
1 | export * from './s-to-ms.util'
2 |
--------------------------------------------------------------------------------
/frontend/src/shared/utils/time-utils/s-to-ms/s-to-ms.util.ts:
--------------------------------------------------------------------------------
1 | export const sToMs = (seconds: number) => seconds * 1_000
2 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/src/widgets/main/installation-guide/installation-guide.base.widget.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconCheck,
3 | IconCloudDownload,
4 | IconDownload,
5 | IconInfoCircle,
6 | IconStar
7 | } from '@tabler/icons-react'
8 | import { Box, Button, Group, Text, ThemeIcon, Timeline } from '@mantine/core'
9 | import { useTranslation } from 'react-i18next'
10 | import { useEffect, useState } from 'react'
11 |
12 | import {
13 | IAppConfig,
14 | ILocalizedText,
15 | TEnabledLocales,
16 | TPlatform
17 | } from '@shared/constants/apps-config/interfaces/app-list.interface'
18 |
19 | import { IPlatformGuideProps } from './interfaces/platform-guide.props.interface'
20 |
21 | export interface IBaseGuideProps extends IPlatformGuideProps {
22 | firstStepTitle: string
23 | platform: TPlatform
24 | renderFirstStepButton: (app: IAppConfig) => React.ReactNode
25 | currentLang: TEnabledLocales
26 | }
27 |
28 | export const BaseInstallationGuideWidget = (props: IBaseGuideProps) => {
29 | const { t } = useTranslation()
30 | const {
31 | openDeepLink,
32 | getAppsForPlatform,
33 | platform,
34 | firstStepTitle,
35 | renderFirstStepButton,
36 | currentLang
37 | } = props
38 |
39 | const platformApps = getAppsForPlatform(platform)
40 | const [activeTabId, setActiveTabId] = useState('')
41 |
42 | useEffect(() => {
43 | if (platformApps.length > 0) {
44 | setActiveTabId(platformApps[0].id)
45 | }
46 | }, [platform, platformApps])
47 |
48 | const handleTabChange = (appId: string) => {
49 | setActiveTabId(appId)
50 | }
51 |
52 | const selectedApp =
53 | (activeTabId && platformApps.find((app) => app.id === activeTabId)) ||
54 | (platformApps.length > 0 ? platformApps[0] : null)
55 |
56 | const formattedTitle = selectedApp
57 | ? firstStepTitle.replace(/{appName}/g, selectedApp.name)
58 | : firstStepTitle
59 |
60 | const getAppDescription = (
61 | app: IAppConfig | null,
62 | step: 'addSubscriptionStep' | 'connectAndUseStep' | 'installationStep'
63 | ) => {
64 | if (!app) return ''
65 |
66 | const stepData = app[step]
67 | if (!stepData) return ''
68 |
69 | return stepData.description[currentLang] || ''
70 | }
71 |
72 | const getButtonText = (button: { buttonText: ILocalizedText }) => {
73 | return button.buttonText[currentLang] || ''
74 | }
75 |
76 | const getStepTitle = (stepData: { title?: ILocalizedText }, defaultTitle: string) => {
77 | if (!stepData || !stepData.title) return defaultTitle
78 |
79 | return stepData.title[currentLang] || defaultTitle
80 | }
81 |
82 | return (
83 |
84 | {platformApps.length > 0 && (
85 |
86 | {platformApps.map((app: IAppConfig) => {
87 | const isActive = app.id === activeTabId
88 | return (
89 | : undefined
94 | }
95 | onClick={() => handleTabChange(app.id)}
96 | styles={{
97 | root: {
98 | padding: '8px 12px',
99 | height: 'auto',
100 | lineHeight: '1.5',
101 | minWidth: 0,
102 | flex: '1 0 auto'
103 | }
104 | }}
105 | variant={isActive ? 'outline' : 'light'}
106 | >
107 | {app.name}
108 |
109 | )
110 | })}
111 |
112 | )}
113 |
114 |
115 |
118 |
119 |
120 | }
121 | title={formattedTitle}
122 | >
123 |
124 | {selectedApp ? getAppDescription(selectedApp, 'installationStep') : ''}
125 |
126 | {selectedApp && renderFirstStepButton(selectedApp)}
127 |
128 |
129 | {selectedApp && selectedApp.additionalBeforeAddSubscriptionStep && (
130 |
133 |
134 |
135 | }
136 | title={getStepTitle(
137 | selectedApp.additionalBeforeAddSubscriptionStep,
138 | 'Additional step title is not set'
139 | )}
140 | >
141 |
142 | {selectedApp.additionalBeforeAddSubscriptionStep.description[
143 | currentLang
144 | ] || selectedApp.additionalBeforeAddSubscriptionStep.description.en}
145 |
146 |
147 | {selectedApp.additionalBeforeAddSubscriptionStep.buttons.map(
148 | (button, index) => (
149 |
158 | )
159 | )}
160 |
161 |
162 | )}
163 |
164 |
167 |
168 |
169 | }
170 | title={t('installation-guide.widget.add-subscription')}
171 | >
172 |
173 | {selectedApp
174 | ? getAppDescription(selectedApp, 'addSubscriptionStep')
175 | : 'Add subscription description is not set'}
176 |
177 | {selectedApp && (
178 |
189 | )}
190 |
191 |
192 | {selectedApp && selectedApp.additionalAfterAddSubscriptionStep && (
193 |
196 |
197 |
198 | }
199 | title={getStepTitle(
200 | selectedApp.additionalAfterAddSubscriptionStep,
201 | 'Additional step title is not set'
202 | )}
203 | >
204 |
205 | {selectedApp.additionalAfterAddSubscriptionStep.description[
206 | currentLang
207 | ] || selectedApp.additionalAfterAddSubscriptionStep.description.en}
208 |
209 |
210 | {selectedApp.additionalAfterAddSubscriptionStep.buttons.map(
211 | (button, index) => (
212 |
221 | )
222 | )}
223 |
224 |
225 | )}
226 |
227 |
230 |
231 |
232 | }
233 | title={t('installation-guide.widget.connect-and-use')}
234 | >
235 |
236 | {selectedApp
237 | ? getAppDescription(selectedApp, 'connectAndUseStep')
238 | : 'Connect and use description is not set'}
239 |
240 |
241 |
242 |
243 | )
244 | }
245 |
--------------------------------------------------------------------------------
/frontend/src/widgets/main/installation-guide/installation-guide.widget.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconBrandAndroid,
3 | IconBrandApple,
4 | IconBrandWindows,
5 | IconDeviceDesktop,
6 | IconExternalLink
7 | } from '@tabler/icons-react'
8 | import { Box, Button, Group, Select, Text } from '@mantine/core'
9 | import { useEffect, useLayoutEffect, useState } from 'react'
10 | import { useTranslation } from 'react-i18next'
11 | import { useOs } from '@mantine/hooks'
12 |
13 | import {
14 | IAppConfig,
15 | ISubscriptionPageAppConfig,
16 | TEnabledLocales,
17 | TPlatform
18 | } from '@shared/constants/apps-config/interfaces/app-list.interface'
19 | import { constructSubscriptionUrl } from '@shared/utils/construct-subscription-url'
20 | import { useSubscriptionInfoStoreInfo } from '@entities/subscription-info-store'
21 |
22 | import { BaseInstallationGuideWidget } from './installation-guide.base.widget'
23 |
24 | export const InstallationGuideWidget = ({
25 | appsConfig,
26 | enabledLocales
27 | }: {
28 | appsConfig: ISubscriptionPageAppConfig['platforms']
29 | enabledLocales: TEnabledLocales[]
30 | }) => {
31 | const { t, i18n } = useTranslation()
32 | const { subscription } = useSubscriptionInfoStoreInfo()
33 |
34 | const os = useOs()
35 |
36 | const [currentLang, setCurrentLang] = useState('en')
37 | const [defaultTab, setDefaultTab] = useState('windows')
38 |
39 | useEffect(() => {
40 | const lang = i18n.language
41 |
42 | if (lang.startsWith('en')) {
43 | setCurrentLang('en')
44 | } else if (lang.startsWith('fa') && enabledLocales.includes('fa')) {
45 | setCurrentLang('fa')
46 | } else if (lang.startsWith('ru') && enabledLocales.includes('ru')) {
47 | setCurrentLang('ru')
48 | } else if (lang.startsWith('zh') && enabledLocales.includes('zh')) {
49 | setCurrentLang('zh')
50 | } else {
51 | setCurrentLang('en')
52 | }
53 | }, [i18n.language])
54 |
55 | useLayoutEffect(() => {
56 | switch (os) {
57 | case 'android':
58 | setDefaultTab('android')
59 | break
60 | case 'ios':
61 | setDefaultTab('ios')
62 | break
63 | case 'linux':
64 | setDefaultTab('linux')
65 | break
66 | case 'macos':
67 | setDefaultTab('macos')
68 | break
69 | case 'windows':
70 | setDefaultTab('windows')
71 | break
72 | default:
73 | setDefaultTab('windows')
74 | break
75 | }
76 | }, [os])
77 |
78 | if (!subscription) return null
79 |
80 | const subscriptionUrl = constructSubscriptionUrl(
81 | window.location.href,
82 | subscription.user.shortUuid
83 | )
84 |
85 | const hasPlatformApps = {
86 | ios: appsConfig.ios && appsConfig.ios.length > 0,
87 | android: appsConfig.android && appsConfig.android.length > 0,
88 | linux: appsConfig.linux && appsConfig.linux.length > 0,
89 | macos: appsConfig.macos && appsConfig.macos.length > 0,
90 | windows: appsConfig.windows && appsConfig.windows.length > 0,
91 | androidTV: appsConfig.androidTV && appsConfig.androidTV.length > 0,
92 | appleTV: appsConfig.appleTV && appsConfig.appleTV.length > 0
93 | }
94 |
95 | if (
96 | !hasPlatformApps.ios &&
97 | !hasPlatformApps.android &&
98 | !hasPlatformApps.linux &&
99 | !hasPlatformApps.macos &&
100 | !hasPlatformApps.windows &&
101 | !hasPlatformApps.androidTV &&
102 | !hasPlatformApps.appleTV
103 | ) {
104 | return null
105 | }
106 |
107 | const openDeepLink = (urlScheme: string, isNeedBase64Encoding: boolean | undefined) => {
108 | if (isNeedBase64Encoding) {
109 | const encoded = btoa(`${subscriptionUrl}`)
110 | const encodedUrl = `${urlScheme}${encoded}`
111 | window.open(encodedUrl, '_blank')
112 | } else {
113 | window.open(`${urlScheme}${subscriptionUrl}`, '_blank')
114 | }
115 | }
116 |
117 | const availablePlatforms = [
118 | hasPlatformApps.android && {
119 | value: 'android',
120 | label: 'Android',
121 | icon:
122 | },
123 | hasPlatformApps.ios && {
124 | value: 'ios',
125 | label: 'iOS',
126 | icon:
127 | },
128 | hasPlatformApps.macos && {
129 | value: 'macos',
130 | label: 'macOS',
131 | icon:
132 | },
133 | hasPlatformApps.windows && {
134 | value: 'windows',
135 | label: 'Windows',
136 | icon:
137 | },
138 | hasPlatformApps.linux && {
139 | value: 'linux',
140 | label: 'Linux',
141 | icon:
142 | },
143 | hasPlatformApps.androidTV && {
144 | value: 'androidTV',
145 | label: 'Android TV',
146 | icon:
147 | },
148 | hasPlatformApps.appleTV && {
149 | value: 'appleTV',
150 | label: 'Apple TV',
151 | icon:
152 | }
153 | ].filter(Boolean) as {
154 | icon: React.ReactNode
155 | label: string
156 | value: string
157 | }[]
158 |
159 | if (
160 | !hasPlatformApps[defaultTab as keyof typeof hasPlatformApps] &&
161 | availablePlatforms.length > 0
162 | ) {
163 | setDefaultTab(availablePlatforms[0].value)
164 | }
165 |
166 | const getAppsForPlatform = (platform: TPlatform) => {
167 | return appsConfig[platform] || []
168 | }
169 |
170 | const getSelectedAppForPlatform = (platform: TPlatform) => {
171 | const apps = getAppsForPlatform(platform)
172 | if (apps.length === 0) return null
173 | return apps[0]
174 | }
175 |
176 | const renderFirstStepButton = (app: IAppConfig) => {
177 | if (app.installationStep.buttons.length > 0) {
178 | return (
179 |
180 | {app.installationStep.buttons.map((button, index) => {
181 | const buttonText = button.buttonText[currentLang] || button.buttonText.en
182 |
183 | return (
184 | }
189 | target="_blank"
190 | variant="light"
191 | >
192 | {buttonText}
193 |
194 | )
195 | })}
196 |
197 | )
198 | }
199 |
200 | return null
201 | }
202 |
203 | const getPlatformTitle = (platform: TPlatform) => {
204 | if (platform === 'android') {
205 | return t('installation-guide.android.widget.install-and-open-app', {
206 | appName: '{appName}'
207 | })
208 | }
209 | if (platform === 'ios') {
210 | return t('installation-guide.ios.widget.install-and-open-app', {
211 | appName: '{appName}'
212 | })
213 | }
214 | if (
215 | platform === 'windows' ||
216 | platform === 'androidTV' ||
217 | platform === 'appleTV' ||
218 | platform === 'linux' ||
219 | platform === 'macos'
220 | ) {
221 | return t('installation-guide.windows.widget.download-app', {
222 | appName: '{appName}'
223 | })
224 | }
225 |
226 | return 'Unknown platform'
227 | }
228 |
229 | return (
230 |
231 |
232 |
233 | {t('installation-guide.widget.installation')}
234 |
235 |
236 | {availablePlatforms.length > 1 && (
237 |
256 |
257 | {hasPlatformApps[defaultTab as keyof typeof hasPlatformApps] && (
258 |
268 | )}
269 |
270 | )
271 | }
272 |
--------------------------------------------------------------------------------
/frontend/src/widgets/main/installation-guide/interfaces/platform-guide.props.interface.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IAppConfig,
3 | ISubscriptionPageAppConfig,
4 | TPlatform
5 | } from '@shared/constants/apps-config/interfaces/app-list.interface'
6 |
7 | export interface IPlatformGuideProps {
8 | getAppsForPlatform: (platform: TPlatform) => IAppConfig[]
9 | getSelectedAppForPlatform: (platform: TPlatform) => IAppConfig | null
10 | openDeepLink: (urlScheme: string, isNeedBase64Encoding: boolean | undefined) => void
11 | appsConfig: ISubscriptionPageAppConfig['platforms']
12 | }
13 |
--------------------------------------------------------------------------------
/frontend/src/widgets/main/subscription-info/subscription-info.widget.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconAlertCircle,
3 | IconArrowsUpDown,
4 | IconCalendar,
5 | IconCheck,
6 | IconUser,
7 | IconX
8 | } from '@tabler/icons-react'
9 | import { Accordion, rgba, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'
10 | import relativeTime from 'dayjs/plugin/relativeTime'
11 | import { useTranslation } from 'react-i18next'
12 | import dayjs from 'dayjs'
13 |
14 | import {
15 | formatDate,
16 | getExpirationTextUtil
17 | } from '@shared/utils/time-utils/get-expiration-text/get-expiration-text.util'
18 | import { useSubscriptionInfoStoreInfo } from '@entities/subscription-info-store'
19 | import { InfoBlockShared } from '@shared/ui/info-block/info-block.shared'
20 |
21 | dayjs.extend(relativeTime)
22 | export const SubscriptionInfoWidget = () => {
23 | const { t, i18n } = useTranslation()
24 | const { subscription } = useSubscriptionInfoStoreInfo()
25 |
26 | if (!subscription) return null
27 |
28 | const { user } = subscription
29 |
30 | const getStatusAndIcon = (): {
31 | color: string
32 | icon: React.ReactNode
33 | status: string
34 | } => {
35 | if (user.userStatus === 'ACTIVE' && user.daysLeft > 0) {
36 | return {
37 | color: 'teal',
38 | icon: ,
39 | status: t('subscription-info.widget.active')
40 | }
41 | }
42 | if (
43 | (user.userStatus === 'ACTIVE' && user.daysLeft === 0) ||
44 | (user.daysLeft >= 0 && user.daysLeft <= 3)
45 | ) {
46 | return {
47 | color: 'orange',
48 | icon: ,
49 | status: t('subscription-info.widget.active')
50 | }
51 | }
52 |
53 | return {
54 | color: 'red',
55 | icon: ,
56 | status: t('subscription-info.widget.inactive')
57 | }
58 | }
59 |
60 | return (
61 | ({
63 | item: {
64 | boxShadow: `0 4px 12px ${rgba(theme.colors.gray[5], 0.1)}`
65 | }
66 | })}
67 | variant="separated"
68 | >
69 |
70 |
73 | {getStatusAndIcon().icon}
74 |
75 | }
76 | >
77 |
78 |
79 | {user.username}
80 |
81 |
82 | {getExpirationTextUtil(user.expiresAt, t, i18n)}
83 |
84 |
85 |
86 |
87 |
88 | }
91 | title={t('subscription-info.widget.name')}
92 | value={user.username}
93 | />
94 |
95 |
100 | ) : (
101 |
102 | )
103 | }
104 | title={t('subscription-info.widget.status')}
105 | value={
106 | user.userStatus === 'ACTIVE'
107 | ? t('subscription-info.widget.active')
108 | : t('subscription-info.widget.inactive')
109 | }
110 | />
111 |
112 | }
115 | title={t('subscription-info.widget.expires')}
116 | value={formatDate(user.expiresAt, t, i18n)}
117 | />
118 |
119 | }
122 | title={t('subscription-info.widget.bandwidth')}
123 | value={`${user.trafficUsed} / ${user.trafficLimit === '0' ? '∞' : user.trafficLimit}`}
124 | />
125 |
126 |
127 |
128 |
129 | )
130 | }
131 |
--------------------------------------------------------------------------------
/frontend/src/widgets/main/subscription-link/subscription-link.widget.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | IconBrandDiscord,
3 | IconBrandTelegram,
4 | IconBrandVk,
5 | IconLink,
6 | IconMessageChatbot
7 | } from '@tabler/icons-react'
8 | import { ActionIcon, Button, Group, Image, Stack, Text } from '@mantine/core'
9 | import { notifications } from '@mantine/notifications'
10 | import { useTranslation } from 'react-i18next'
11 | import { useClipboard } from '@mantine/hooks'
12 | import { modals } from '@mantine/modals'
13 | import { renderSVG } from 'uqr'
14 |
15 | import { constructSubscriptionUrl } from '@shared/utils/construct-subscription-url'
16 | import { useSubscriptionInfoStoreInfo } from '@entities/subscription-info-store'
17 |
18 | export const SubscriptionLinkWidget = ({ supportUrl }: { supportUrl?: string }) => {
19 | const { t } = useTranslation()
20 | const { subscription } = useSubscriptionInfoStoreInfo()
21 | const clipboard = useClipboard({ timeout: 10000 })
22 |
23 | if (!subscription) return null
24 |
25 | const subscriptionUrl = constructSubscriptionUrl(
26 | window.location.href,
27 | subscription.user.shortUuid
28 | )
29 |
30 | const handleCopy = () => {
31 | notifications.show({
32 | title: t('subscription-link.widget.link-copied'),
33 | message: t('subscription-link.widget.link-copied-to-clipboard'),
34 | color: 'teal'
35 | })
36 | clipboard.copy(subscriptionUrl)
37 | }
38 |
39 | const renderSupportLink = (supportUrl: string) => {
40 | const iconConfig = {
41 | 't.me': { icon: IconBrandTelegram, color: '#0088cc' },
42 | 'discord.com': { icon: IconBrandDiscord, color: '#5865F2' },
43 | 'vk.com': { icon: IconBrandVk, color: '#0077FF' }
44 | }
45 |
46 | const matchedPlatform = Object.entries(iconConfig).find(([domain]) =>
47 | supportUrl.includes(domain)
48 | )
49 |
50 | const { icon: Icon, color } = matchedPlatform
51 | ? matchedPlatform[1]
52 | : { icon: IconMessageChatbot, color: 'teal' }
53 |
54 | return (
55 |
64 |
65 |
66 | )
67 | }
68 |
69 | return (
70 |
71 | {
73 | const subscriptionQrCode = renderSVG(subscriptionUrl, {
74 | whiteColor: '#161B22',
75 | blackColor: '#3CC9DB'
76 | })
77 |
78 | modals.open({
79 | centered: true,
80 | title: t('subscription-link.widget.get-link'),
81 | children: (
82 | <>
83 |
84 |
87 |
88 | {t('subscription-link.widget.scan-qr-code')}
89 |
90 |
91 | {t('subscription-link.widget.line-1')}
92 |
93 |
94 |
97 |
98 | >
99 | )
100 | })
101 | }}
102 | size="xl"
103 | variant="default"
104 | >
105 |
106 |
107 | {supportUrl && renderSupportLink(supportUrl)}
108 |
109 | )
110 | }
111 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "removeComments": true,
4 | "target": "ES2020",
5 | "lib": [
6 | "ESNext",
7 | "DOM",
8 | "DOM.Iterable",
9 | ],
10 | "module": "ESNext",
11 | "skipLibCheck": true,
12 | /* ------------------------------------------------------------ */
13 | /* Bundler mode */
14 | /* ------------------------------------------------------------ */
15 | "moduleResolution": "bundler",
16 | "allowImportingTsExtensions": true,
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react-jsx",
21 | /* ------------------------------------------------------------ */
22 | /* "useDefineForClassFields": true,
23 | /* "esModuleInterop": false,
24 | /* "allowSyntheticDefaultImports": true,
25 | "allowJs": false,
26 |
27 | /* ------------------------------------------------------------ */
28 | "strict": true,
29 | "noUnusedLocals": false,
30 | "noUnusedParameters": false, // TURN THIS
31 | "noFallthroughCasesInSwitch": true,
32 | "strictPropertyInitialization": false,
33 | "forceConsistentCasingInFileNames": false,
34 | /* ------------------------------------------------------------ */
35 | /* Paths */
36 | /* ------------------------------------------------------------ */
37 | "baseUrl": ".",
38 | "paths": {
39 | "@entities/*": [
40 | "./src/entities/*"
41 | ],
42 | "@features/*": [
43 | "./src/features/*"
44 | ],
45 | "@pages/*": [
46 | "./src/pages/*"
47 | ],
48 | "@widgets/*": [
49 | "./src/widgets/*"
50 | ],
51 | "@public/*": [
52 | "./public/*"
53 | ],
54 | "@shared/*": [
55 | "./src/shared/*"
56 | ],
57 | }
58 | },
59 | "include": [
60 | "src",
61 | "@types"
62 | ],
63 | "references": [
64 | {
65 | "path": "./tsconfig.node.json"
66 | }
67 | ]
68 | }
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": [
11 | "vite.config.ts"
12 | ]
13 | }
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable indent */
2 | import obfuscatorPlugin from 'vite-plugin-javascript-obfuscator'
3 | import removeConsole from 'vite-plugin-remove-console'
4 | // import { visualizer } from 'rollup-plugin-visualizer'
5 | import webfontDownload from 'vite-plugin-webfont-dl'
6 | import tsconfigPaths from 'vite-tsconfig-paths'
7 | import { fileURLToPath, URL } from 'node:url'
8 | import react from '@vitejs/plugin-react-swc'
9 | import { defineConfig } from 'vite'
10 | // import deadFile from 'vite-plugin-deadfile'
11 |
12 | export default defineConfig({
13 | plugins: [
14 | react(),
15 | tsconfigPaths(),
16 | removeConsole(),
17 | webfontDownload(undefined, {}),
18 | obfuscatorPlugin({
19 | exclude: [/node_modules/, /app.tsx/],
20 | apply: 'build',
21 | debugger: false,
22 | options: {
23 | compact: true,
24 | controlFlowFlattening: false,
25 | deadCodeInjection: false,
26 | debugProtection: true,
27 | debugProtectionInterval: 0,
28 | domainLock: [],
29 | disableConsoleOutput: true,
30 | identifierNamesGenerator: 'hexadecimal',
31 | log: false,
32 | numbersToExpressions: false,
33 | renameGlobals: false,
34 | selfDefending: false,
35 | simplify: true,
36 | splitStrings: false,
37 | stringArray: true,
38 | stringArrayCallsTransform: false,
39 | stringArrayCallsTransformThreshold: 0.5,
40 | stringArrayEncoding: [],
41 | stringArrayIndexShift: true,
42 | stringArrayRotate: true,
43 | stringArrayShuffle: true,
44 | stringArrayWrappersCount: 1,
45 | stringArrayWrappersChainedCalls: true,
46 | stringArrayWrappersParametersMaxCount: 2,
47 | stringArrayWrappersType: 'variable',
48 | stringArrayThreshold: 0.75,
49 | unicodeEscapeSequence: false
50 | // ... [See more options](https://github.com/javascript-obfuscator/javascript-obfuscator)
51 | }
52 | })
53 | // visualizer() as PluginOption
54 | ],
55 | optimizeDeps: {
56 | include: ['html-parse-stringify']
57 | },
58 |
59 | build: {
60 | target: 'esNext',
61 |
62 | outDir: 'dist',
63 | rollupOptions: {
64 | output: {
65 | manualChunks: {
66 | react: ['react', 'react-dom', 'react-router-dom', 'zustand'],
67 | icons: ['react-icons/pi'],
68 | date: ['dayjs'],
69 | mantine: [
70 | '@mantine/core',
71 | '@mantine/hooks',
72 | '@mantine/dates',
73 | '@mantine/nprogress',
74 | '@mantine/notifications',
75 | '@mantine/modals',
76 | '@remnawave/backend-contract'
77 | ],
78 | i18n: ['i18next', 'i18next-http-backend', 'i18next-browser-languagedetector'],
79 | motion: ['framer-motion']
80 | }
81 | }
82 | }
83 | },
84 | define: {},
85 | server: {
86 | host: '0.0.0.0',
87 | port: 3334,
88 | cors: false,
89 | strictPort: true
90 | },
91 | resolve: {
92 | alias: {
93 | '@entities': fileURLToPath(new URL('./src/entities', import.meta.url)),
94 | '@features': fileURLToPath(new URL('./src/features', import.meta.url)),
95 | '@pages': fileURLToPath(new URL('./src/pages', import.meta.url)),
96 | '@widgets': fileURLToPath(new URL('./src/widgets', import.meta.url)),
97 | '@public': fileURLToPath(new URL('./public', import.meta.url)),
98 | '@shared': fileURLToPath(new URL('./src/shared', import.meta.url))
99 | }
100 | }
101 | })
102 |
--------------------------------------------------------------------------------