diff --git a/README.md b/README.md index 3e2a827e..d3fdd070 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,21 @@ ## Remnawave Subscription Page +### INCY encrypted subscription links + +This fork adds support for INCY encrypted deep links (`incy://crypt1/...`), similar to +the existing `HAPP_CRYPT4_LINK` placeholder for Happ. + +**Setup:** +1. Install the dependency in `frontend`: `npm install @densds/link-encoder` +2. In your app-config JSON (Subscription Page settings), use the placeholder + `{{INCY_CRYPT1_LINK}}` as the `link` value for an INCY button. + +Encryption happens entirely client-side, the same way `HAPP_CRYPT3_LINK` / +`HAPP_CRYPT4_LINK` already work: the subscription URL is built from the already-loaded, +already-validated subscription info (`constructSubscriptionUrl`), never from +unvalidated user input, so there's no backend endpoint involved and nothing for it to +expose. + Learn more about Remnawave [here](https://docs.rw/). # Contributors diff --git a/backend/package.json b/backend/package.json index 4b070d18..e027e25b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -81,4 +81,4 @@ "typescript": "~5.9.3", "webpack-node-externals": "^3.0.0" } -} +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c1bb6707..10699c35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,17 @@ services: build: context: . dockerfile: Dockerfile + container_name: remnawave-subscription-page + hostname: remnawave-subscription-page + restart: always env_file: - .env ports: - '127.0.0.1:3010:3010' + networks: + - remnawave-network + +networks: + remnawave-network: + driver: bridge + external: true \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index df05f926..23df2aa1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,6 +2,27 @@ + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f52b077..986e9293 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "8.0.0", "license": "AGPL-3.0-only", "dependencies": { + "@densds/link-encoder": "^2.0.0", "@gfazioli/mantine-spinner": "4.1.8", "@kastov/cryptohapp": "^1.1.2", "@mantine/core": "9.4.1", @@ -587,6 +588,15 @@ "node": ">=18" } }, + "node_modules/@densds/link-encoder": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@densds/link-encoder/-/link-encoder-2.0.1.tgz", + "integrity": "sha512-lvLW2CBXmdEK+jlX1KNzKHenN4OLHH0BUsvQme9ODZWHJ5FBKLZHg5Mn7dqDgp82uqa684QmYtfBSJWkXrMVyA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 34360ccd..ae1796f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@gfazioli/mantine-spinner": "4.1.8", + "@densds/link-encoder": "^2.0.0", "@kastov/cryptohapp": "^1.1.2", "@mantine/core": "9.4.1", "@mantine/hooks": "9.4.1", diff --git a/frontend/src/widgets/main/installation-guide/installation-guide.connector.tsx b/frontend/src/widgets/main/installation-guide/installation-guide.connector.tsx index 6bcf5359..69c38d8b 100644 --- a/frontend/src/widgets/main/installation-guide/installation-guide.connector.tsx +++ b/frontend/src/widgets/main/installation-guide/installation-guide.connector.tsx @@ -15,8 +15,9 @@ import { UnstyledButton } from '@mantine/core' import { notifications } from '@mantine/notifications' +import { encryptLink } from '@densds/link-encoder' import { useClipboard } from '@mantine/hooks' -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import clsx from 'clsx' import { constructSubscriptionUrl } from '@shared/utils/construct-subscription-url' @@ -32,6 +33,16 @@ import classes from './installation-guide.module.css' export type TBlockVariant = 'accordion' | 'cards' | 'minimal' | 'timeline' +// window.open (not location.href) is what actually hands off custom schemes +// (happ://, incy://, ...) to the OS from inside Telegram's Mini App WebView, same +// as the validated fix in the sibling shopbot project — as long as it's called +// synchronously, with no async gap burning through the click's user-activation +// window. It also keeps regular http(s) subscription links in a new tab instead +// of navigating the page itself away. +const navigateToUrl = (url: string) => { + window.open(url, '_blank') +} + interface IProps { BlockRenderer: React.ComponentType hasPlatformApps: Record @@ -81,10 +92,61 @@ export const InstallationGuideConnector = (props: IProps) => { subscription.user.shortUuid ) + const hasIncyButton = useMemo( + () => + Object.values(platforms).some((platformConfig) => + platformConfig?.apps.some((app) => + app.blocks.some((block) => + block.buttons.some((button) => button.link === '{{INCY_CRYPT1_LINK}}') + ) + ) + ), + [platforms] + ) + + const [incyCryptLink, setIncyCryptLink] = useState(undefined) + const [incyCryptLoading, setIncyCryptLoading] = useState(() => hasIncyButton) + + useEffect(() => { + if (!hasIncyButton) return + + let cancelled = false + + // name is capped at 128 chars per @densds/link-encoder's encryptLink contract + const name = subscription.user.username.slice(0, 128) + + encryptLink(subscriptionUrl, { name }) + .then((link) => { + if (!cancelled) setIncyCryptLink(link) + }) + .catch(() => { + // incyCryptLink stays undefined; handleButtonClick surfaces + // the "not ready" notification when the user clicks the button + }) + .finally(() => { + if (!cancelled) setIncyCryptLoading(false) + }) + + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasIncyButton, subscriptionUrl, subscription.user.username]) + const handleButtonClick = (button: TSubscriptionPageButtonConfig) => { let formattedUrl: string | undefined - if (button.type === 'subscriptionLink' || button.type === 'copyButton') { + if (button.link === '{{INCY_CRYPT1_LINK}}') { + if (!incyCryptLink) { + notifications.show({ + title: 'Error', + message: 'INCY link is not ready yet, please try again in a moment', + color: 'red' + }) + return + } + formattedUrl = incyCryptLink + } else if (button.type === 'subscriptionLink' || button.type === 'copyButton') { formattedUrl = TemplateEngine.formatWithMetaInfo(button.link, { username: subscription.user.username, subscriptionUrl @@ -104,13 +166,17 @@ export const InstallationGuideConnector = (props: IProps) => { break } case 'external': { - window.open(button.link, '_blank') + if (formattedUrl) { + navigateToUrl(formattedUrl) + } else { + window.open(button.link, '_blank') + } break } case 'subscriptionLink': { if (!formattedUrl) return - window.open(formattedUrl, '_blank') + navigateToUrl(formattedUrl) break } default: @@ -129,6 +195,7 @@ export const InstallationGuideConnector = (props: IProps) => { {buttons.map((button, index) => (