fix(connection): keep the Happ add-subscription button working on Remnawave 2.8.0

Panel 2.8.0 removed the server-side happ-encrypt endpoint, so for users
without a stored crypto link the backend leaves {{HAPP_CRYPT4_LINK}}
unresolved and the subscriptionLink button silently disappeared
(isValidDeepLink requires '://').

- BlockButtons: resolve button templates client-side at render (with username,
  like the panel's own subpage) instead of dropping the button; collapse
  double-prefixed happ://crypt4/happ://cryptN/... resolvedUrl from old backends
- templateEngine: collapse a hardcoded happ://cryptN/ prefix before
  {{HAPP_CRYPT[34]_LINK}}; cache crypt-link generation (jsencrypt RSA-4096 is
  slow on weak devices and re-randomizes every call)
- Connection: in happ_cryptolink mode force the crypt link only when the button
  fell back to the plain subscription link or kept unresolved templates - an
  explicit Subpage link (e.g. happ://add/...) now wins, so Subpage edits apply
This commit is contained in:
Fringg
2026-07-02 16:12:18 +03:00
parent 984d1b0776
commit 8f31311112
4 changed files with 71 additions and 9 deletions

View File

@@ -48,6 +48,7 @@ interface Props {
isTelegramWebApp: boolean; isTelegramWebApp: boolean;
onGoBack: () => void; onGoBack: () => void;
onOpenQR?: () => void; onOpenQR?: () => void;
username?: string;
} }
export default function InstallationGuide({ export default function InstallationGuide({
@@ -56,6 +57,7 @@ export default function InstallationGuide({
isTelegramWebApp, isTelegramWebApp,
onGoBack, onGoBack,
onOpenQR, onOpenQR,
username,
}: Props) { }: Props) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { isLight } = useTheme(); const { isLight } = useTheme();
@@ -131,6 +133,7 @@ export default function InstallationGuide({
subscriptionUrl={appConfig.subscriptionUrl} subscriptionUrl={appConfig.subscriptionUrl}
hideLink={appConfig.hideLink} hideLink={appConfig.hideLink}
deepLink={selectedApp?.deepLink} deepLink={selectedApp?.deepLink}
username={username}
getLocalizedText={getLocalizedText} getLocalizedText={getLocalizedText}
getBaseTranslation={getBaseTranslation} getBaseTranslation={getBaseTranslation}
getSvgHtml={getSvgHtml} getSvgHtml={getSvgHtml}
@@ -141,6 +144,7 @@ export default function InstallationGuide({
appConfig.subscriptionUrl, appConfig.subscriptionUrl,
appConfig.hideLink, appConfig.hideLink,
selectedApp?.deepLink, selectedApp?.deepLink,
username,
isLight, isLight,
getLocalizedText, getLocalizedText,
getBaseTranslation, getBaseTranslation,

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CheckIcon, CopyIcon } from '@/components/icons'; import { CheckIcon, CopyIcon } from '@/components/icons';
import type { RemnawaveButtonClient, LocalizedText } from '@/types'; import type { RemnawaveButtonClient, LocalizedText } from '@/types';
import { copyToClipboard } from '@/utils/clipboard'; import { copyToClipboard } from '@/utils/clipboard';
import { collapseDoubledCryptPrefix, hasTemplates, resolveTemplate } from '@/utils/templateEngine';
import { blockButtonClass } from './buttonStyles'; import { blockButtonClass } from './buttonStyles';
// eslint-disable-next-line no-script-url // eslint-disable-next-line no-script-url
@@ -29,6 +30,7 @@ interface BlockButtonsProps {
subscriptionUrl: string | null; subscriptionUrl: string | null;
hideLink?: boolean; hideLink?: boolean;
deepLink?: string | null; deepLink?: string | null;
username?: string;
getLocalizedText: (text: LocalizedText | undefined) => string; getLocalizedText: (text: LocalizedText | undefined) => string;
getBaseTranslation: (key: string, i18nKey: string) => string; getBaseTranslation: (key: string, i18nKey: string) => string;
getSvgHtml: (key: string | undefined) => string; getSvgHtml: (key: string | undefined) => string;
@@ -42,6 +44,7 @@ export function BlockButtons({
subscriptionUrl, subscriptionUrl,
hideLink, hideLink,
deepLink, deepLink,
username,
getLocalizedText, getLocalizedText,
getBaseTranslation, getBaseTranslation,
getSvgHtml, getSvgHtml,
@@ -73,8 +76,17 @@ export function BlockButtons({
) : null; ) : null;
if (btn.type === 'subscriptionLink') { if (btn.type === 'subscriptionLink') {
const url = btn.resolvedUrl || btn.url || btn.link || deepLink || subscriptionUrl; const raw = btn.resolvedUrl || btn.url || btn.link || deepLink || subscriptionUrl;
if (!url || !isValidDeepLink(url)) return null; // The backend resolves {{HAPP_CRYPT*_LINK}} only when a crypto link is
// stored in the DB; since Remnawave 2.8.0 removed the encrypt endpoint,
// new users can get the raw template here. Resolve it client-side (the
// panel's own subpage does the same) instead of dropping the button.
const resolved =
raw && subscriptionUrl && hasTemplates(raw)
? resolveTemplate(raw, { subscriptionUrl, username })
: raw;
const url = resolved ? collapseDoubledCryptPrefix(resolved) : resolved;
if (!url || hasTemplates(url) || !isValidDeepLink(url)) return null;
return ( return (
<button <button
key={idx} key={idx}

View File

@@ -114,12 +114,20 @@ export default function Connection() {
const openDeepLink = useCallback( const openDeepLink = useCallback(
(deepLink: string) => { (deepLink: string) => {
let resolved = deepLink; let resolved = deepLink;
if (isHappCryptolinkMode(connectionLink?.connect_mode) && qrConnectionUrl) { if (hasTemplates(resolved)) {
// In HAPP cryptolink mode always open the resolved happ://crypt... URL.
resolved = qrConnectionUrl;
} else if (hasTemplates(resolved)) {
resolved = resolveUrl(resolved); resolved = resolveUrl(resolved);
} }
// In HAPP cryptolink mode keep hiding the plain subscription link: force the
// happ://crypt... URL only when the button fell back to it or its template
// could not be resolved. An explicit link from the panel's Subpage config
// (e.g. happ://add/...) wins — admins expect Subpage edits to apply here.
if (
isHappCryptolinkMode(connectionLink?.connect_mode) &&
qrConnectionUrl &&
(!resolved || resolved === appConfig?.subscriptionUrl || hasTemplates(resolved))
) {
resolved = qrConnectionUrl;
}
const isHttpUrl = /^https?:\/\//i.test(resolved); const isHttpUrl = /^https?:\/\//i.test(resolved);
const finalUrlForTelegram = isHttpUrl const finalUrlForTelegram = isHttpUrl
? resolved ? resolved
@@ -140,7 +148,14 @@ export default function Connection() {
// still navigate normally. (Telegram bug #654272.) // still navigate normally. (Telegram bug #654272.)
openAppScheme(resolved); openAppScheme(resolved);
}, },
[isTelegramWebApp, i18n.language, resolveUrl, connectionLink?.connect_mode, qrConnectionUrl], [
isTelegramWebApp,
i18n.language,
resolveUrl,
connectionLink?.connect_mode,
qrConnectionUrl,
appConfig?.subscriptionUrl,
],
); );
// Check if any platform has configured apps // Check if any platform has configured apps
@@ -217,6 +232,7 @@ export default function Connection() {
isTelegramWebApp={isTelegramWebApp} isTelegramWebApp={isTelegramWebApp}
onGoBack={handleGoBack} onGoBack={handleGoBack}
onOpenQR={handleOpenQR} onOpenQR={handleOpenQR}
username={user?.username ?? undefined}
/> />
); );
} }

View File

@@ -6,6 +6,31 @@ export function hasTemplates(url: string): boolean {
return TEMPLATE_RE.test(url); return TEMPLATE_RE.test(url);
} }
/**
* Old bot backends resolve prefix-hardcoded Subpage templates
* (happ://crypt4/{{HAPP_CRYPT4_LINK}}) into happ://crypt4/happ://cryptN/... —
* strip the outer prefix so the deep link stays openable.
*/
export function collapseDoubledCryptPrefix(url: string): string {
return url.replace(/^happ:\/\/crypt\d+\/(?=happ:\/\/crypt)/i, '');
}
// jsencrypt's RSA-4096 takes up to tens of ms on weak devices and its padding is
// random (a new string every call) — cache per subscription URL so render-time
// resolution is cheap and stable within a session. Any single ciphertext is valid.
const cryptLinkCache = new Map<string, string | null>();
function cachedHappCryptoLink(url: string, version: 'v3' | 'v4'): string | null {
const key = `${version}|${url}`;
let link = cryptLinkCache.get(key);
if (link === undefined) {
if (cryptLinkCache.size >= 100) cryptLinkCache.clear();
link = createHappCryptoLink(url, version, true);
cryptLinkCache.set(key, link);
}
return link;
}
interface ResolveContext { interface ResolveContext {
subscriptionUrl: string; subscriptionUrl: string;
username?: string; username?: string;
@@ -14,6 +39,11 @@ interface ResolveContext {
export function resolveTemplate(template: string, ctx: ResolveContext): string { export function resolveTemplate(template: string, ctx: ResolveContext): string {
let result = template; let result = template;
// {{HAPP_CRYPT*_LINK}} resolves to a FULL happ://crypt.../ deep link; when the
// template also hardcodes the prefix (happ://crypt4/{{HAPP_CRYPT4_LINK}}),
// collapse it so we don't produce happ://crypt4/happ://crypt5/...
result = result.replace(/happ:\/\/crypt\d+\/(?=\{\{HAPP_CRYPT[34]_LINK\}\})/gi, '');
result = result.replace(/\{\{SUBSCRIPTION_LINK\}\}/g, ctx.subscriptionUrl); result = result.replace(/\{\{SUBSCRIPTION_LINK\}\}/g, ctx.subscriptionUrl);
if (ctx.username) { if (ctx.username) {
@@ -21,11 +51,11 @@ export function resolveTemplate(template: string, ctx: ResolveContext): string {
} }
result = result.replace(/\{\{HAPP_CRYPT3_LINK\}\}/g, () => { result = result.replace(/\{\{HAPP_CRYPT3_LINK\}\}/g, () => {
return createHappCryptoLink(ctx.subscriptionUrl, 'v3', true) ?? ctx.subscriptionUrl; return cachedHappCryptoLink(ctx.subscriptionUrl, 'v3') ?? ctx.subscriptionUrl;
}); });
result = result.replace(/\{\{HAPP_CRYPT4_LINK\}\}/g, () => { result = result.replace(/\{\{HAPP_CRYPT4_LINK\}\}/g, () => {
return createHappCryptoLink(ctx.subscriptionUrl, 'v4', true) ?? ctx.subscriptionUrl; return cachedHappCryptoLink(ctx.subscriptionUrl, 'v4') ?? ctx.subscriptionUrl;
}); });
return result; return result;