├── .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 | logo 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 | setOpened(false)} 74 | onOpen={() => setOpened(true)} 75 | radius="md" 76 | width="target" 77 | withinPortal 78 | > 79 | 80 | 87 | 88 | {items} 89 | 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 | 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 | 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 |