feat: dynamic subpage configuration

This commit is contained in:
kastov 2025-12-15 03:30:13 +03:00
parent 9a7b6d721b
commit 60e3c4df7a
No known key found for this signature in database
GPG key ID: 1B27BE29057F4C90
18 changed files with 309 additions and 117 deletions

2
.gitignore vendored
View file

@ -141,4 +141,4 @@ docker-compose-local.yml
/backend/.env
/frontend/public/assets/app-config-v2.json
/frontend/public/assets/.app-config-v2.json

View file

@ -1,12 +1,12 @@
{
"name": "@remnawave/subscription-page",
"version": "6.4.2",
"version": "7.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@remnawave/subscription-page",
"version": "6.4.2",
"version": "7.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@kastov/request-ip": "^0.0.5",
@ -18,8 +18,8 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "11.1.9",
"@nestjs/serve-static": "5.0.4",
"@remnawave/backend-contract": "2.3.55",
"@remnawave/subscription-page-types": "0.0.6",
"@remnawave/backend-contract": "2.3.57",
"@remnawave/subscription-page-types": "0.1.0",
"axios": "^1.13.2",
"class-transformer": "^0.5.1",
"compression": "^1.8.1",
@ -1299,7 +1299,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.9.tgz",
"integrity": "sha512-zDntUTReRbAThIfSp3dQZ9kKqI+LjgLp5YZN5c1bgNRDuoeLySAoZg46Bg1a+uV8TMgIRziHocglKGNzr6l+bQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"file-type": "21.1.0",
"iterare": "1.2.1",
@ -1347,7 +1346,6 @@
"integrity": "sha512-a00B0BM4X+9z+t3UxJqIZlemIwCQdYoPKrMcM+ky4z3pkqqG1eTWexjs+YXpGObnLnjtMPVKWlcZHp3adDYvUw==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1",
@ -1401,7 +1399,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.9.tgz",
"integrity": "sha512-GVd3+0lO0mJq2m1kl9hDDnVrX3Nd4oH3oDfklz0pZEVEVS0KVSp63ufHq2Lu9cyPdSBuelJr9iPm2QQ1yX+Kmw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cors": "2.8.5",
"express": "5.1.0",
@ -1791,18 +1788,18 @@
}
},
"node_modules/@remnawave/backend-contract": {
"version": "2.3.55",
"resolved": "https://registry.npmjs.org/@remnawave/backend-contract/-/backend-contract-2.3.55.tgz",
"integrity": "sha512-8ygk01Clk4NTT5XBQgLeK34fAqDS2O794bTjTZKoHunWMuy5LLl46vtCCqMgZtv8umyZTE84eLSNnbc5TWjwYw==",
"version": "2.3.57",
"resolved": "https://registry.npmjs.org/@remnawave/backend-contract/-/backend-contract-2.3.57.tgz",
"integrity": "sha512-9ayCEO2GTXmF0EjjIz1bRp+4LXuW6PUuILJsuJwuaXOUhiS1AqZ7MFfXDb2n1ZVUAP6llxaRc2NjCPfdEJW//w==",
"license": "AGPL-3.0-only",
"dependencies": {
"zod": "3.25.76"
}
},
"node_modules/@remnawave/subscription-page-types": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@remnawave/subscription-page-types/-/subscription-page-types-0.0.6.tgz",
"integrity": "sha512-eqr0F/TiRHOjI5MMGK3GGcpkeACCejSP4+8aH/ip1ZNxW6iJTaVCLxDbvEcPKx1PaP3BVtsMEiEmlgep0sEBJg==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@remnawave/subscription-page-types/-/subscription-page-types-0.1.0.tgz",
"integrity": "sha512-1jJUT49EP+BcE0Vx8Vx8A4TdlO/vsBgoxljhyN5hp5PalcTM0WOhDpPIlyAzlqryI5b4FFJ6LEjAnbQ48Nd25Q==",
"license": "AGPL-3.0-only",
"dependencies": {
"zod": "3.25.76"
@ -1934,7 +1931,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@ -1964,7 +1960,6 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@ -2043,7 +2038,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz",
"integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -2152,7 +2146,6 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0",
@ -2554,7 +2547,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2612,7 +2604,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -2802,7 +2793,6 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@ -2966,7 +2956,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3168,7 +3157,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@ -3193,8 +3181,7 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/cli-cursor": {
"version": "3.1.0",
@ -3809,7 +3796,6 @@
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"jake": "^10.8.5"
},
@ -4008,7 +3994,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -4069,7 +4054,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -4387,7 +4371,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
@ -5786,8 +5769,7 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
@ -7039,7 +7021,6 @@
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -7228,8 +7209,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/require-from-string": {
"version": "2.0.2",
@ -7344,7 +7324,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@ -7415,7 +7394,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -8354,7 +8332,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8566,7 +8543,6 @@
"integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@ -8735,7 +8711,6 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@ -8902,7 +8877,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -1,6 +1,6 @@
{
"name": "@remnawave/subscription-page",
"version": "6.4.2",
"version": "7.0.0",
"description": "Remnawave Subscription Page",
"private": false,
"type": "commonjs",
@ -36,8 +36,8 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "11.1.9",
"@nestjs/serve-static": "5.0.4",
"@remnawave/backend-contract": "2.3.55",
"@remnawave/subscription-page-types": "0.0.6",
"@remnawave/backend-contract": "2.3.57",
"@remnawave/subscription-page-types": "0.1.0",
"axios": "^1.13.2",
"class-transformer": "^0.5.1",
"compression": "^1.8.1",
@ -96,4 +96,4 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.49.0"
}
}
}

View file

@ -1,3 +1,5 @@
import type { IncomingHttpHeaders } from 'node:http';
import axios, {
AxiosError,
AxiosInstance,
@ -12,13 +14,14 @@ import { ConfigService } from '@nestjs/config';
import {
GetStatusCommand,
GetSubpageConfigByShortUuidCommand,
GetSubscriptionInfoByShortUuidCommand,
GetSubscriptionPageConfigCommand,
GetSubscriptionPageConfigsCommand,
GetUserByUsernameCommand,
REMNAWAVE_REAL_IP_HEADER,
TRequestTemplateTypeKeys,
} from '@remnawave/backend-contract';
import { SubscriptionPageRawConfigSchema } from '@remnawave/subscription-page-types';
import { ICommandResponse } from '../types/command-response.type';
@ -26,12 +29,8 @@ import { ICommandResponse } from '../types/command-response.type';
export class AxiosService implements OnModuleInit {
public axiosInstance: AxiosInstance;
private readonly logger = new Logger(AxiosService.name);
private readonly subpageConfigUuid: string;
private subscriptionPageConfig: object | null = null;
constructor(private readonly configService: ConfigService) {
this.subpageConfigUuid = this.configService.getOrThrow<string>('SUBPAGE_CONFIG_UUID');
this.axiosInstance = axios.create({
baseURL: this.configService.getOrThrow('REMNAWAVE_PANEL_URL'),
timeout: 10_000,
@ -96,25 +95,6 @@ export class AxiosService implements OnModuleInit {
} else {
this.logger.log('Connection to Remnawave established successfully.');
}
const subscriptionPageConfig = await this.getSubscriptionPageConfig();
if (!subscriptionPageConfig.isOk || !subscriptionPageConfig.response) {
this.logger.error('Subpage config cannot be fetched');
exit(1);
} else {
const parsedConfig = await SubscriptionPageRawConfigSchema.safeParseAsync(
subscriptionPageConfig.response.response.config,
);
if (!parsedConfig.success) {
this.logger.error('Subpage config is not valid', parsedConfig.error);
exit(1);
} else {
this.subscriptionPageConfig = parsedConfig.data;
}
this.logger.log('Subpage config fetched successfully');
}
}
public async getAuthStatus(): Promise<{
@ -179,26 +159,43 @@ export class AxiosService implements OnModuleInit {
}
}
public async getSubscriptionPageConfig(): Promise<
ICommandResponse<GetSubscriptionPageConfigCommand.Response>
> {
public async getSubscriptionPageConfigByUuid(
uuid: string,
): Promise<ICommandResponse<GetSubscriptionPageConfigCommand.Response['response']>> {
try {
const response =
await this.axiosInstance.request<GetSubscriptionPageConfigCommand.Response>({
method: GetSubscriptionPageConfigCommand.endpointDetails.REQUEST_METHOD,
url: GetSubscriptionPageConfigCommand.url(this.subpageConfigUuid),
url: GetSubscriptionPageConfigCommand.url(uuid),
});
return {
isOk: true,
response: response.data,
response: response.data.response,
};
} catch (error) {
if (error instanceof AxiosError) {
this.logger.error('Error in GetSubscriptionPageConfig Request:', error.message);
} else {
this.logger.error('Error in GetSubscriptionPageConfig Request:', error);
}
this.logger.error('Error in GetSubscriptionPageConfigByUuid Request:', error);
return { isOk: false };
}
}
public async getSubscriptionPageConfigList(): Promise<
ICommandResponse<GetSubscriptionPageConfigsCommand.Response['response']>
> {
try {
const response =
await this.axiosInstance.request<GetSubscriptionPageConfigsCommand.Response>({
method: GetSubscriptionPageConfigsCommand.endpointDetails.REQUEST_METHOD,
url: GetSubscriptionPageConfigsCommand.url,
});
return {
isOk: true,
response: response.data.response,
};
} catch (error) {
this.logger.error('Error in GetSubscriptionPageConfigList Request:', error);
return { isOk: false };
}
@ -233,6 +230,30 @@ export class AxiosService implements OnModuleInit {
}
}
public async getSubpageConfig(
shortUuid: string,
requestHeaders: IncomingHttpHeaders,
): Promise<ICommandResponse<GetSubpageConfigByShortUuidCommand.Response['response']>> {
try {
const response =
await this.axiosInstance.request<GetSubpageConfigByShortUuidCommand.Response>({
method: GetSubpageConfigByShortUuidCommand.endpointDetails.REQUEST_METHOD,
url: GetSubpageConfigByShortUuidCommand.url(shortUuid),
data: {
requestHeaders,
},
});
return {
isOk: true,
response: response.data.response,
};
} catch (error) {
this.logger.error('Error in GetSubpageConfig Request:', error);
return { isOk: false };
}
}
public async getSubscription(
clientIp: string,
shortUuid: string,
@ -295,12 +316,4 @@ export class AxiosService implements OnModuleInit {
return filteredHeaders;
}
public getCachedSubscriptionPageConfig(): object {
if (!this.subscriptionPageConfig) {
throw new Error('Subpage config is not cached');
}
return this.subscriptionPageConfig;
}
}

View file

@ -1 +1,2 @@
export * from './errors';
export * from './jwt-payload.interface';

View file

@ -0,0 +1,4 @@
export interface IJwtPayload {
sessionId: string;
su: string;
}

View file

@ -0,0 +1,6 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const GetJWTPayload = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
});

View file

@ -0,0 +1 @@
export * from './get-jwt-payload';

View file

@ -3,9 +3,15 @@ import * as jwt from 'jsonwebtoken';
import { Logger } from '@nestjs/common';
import { IJwtPayload } from '@common/constants';
const logger = new Logger('CheckAssetsCookieMiddleware');
export function checkAssetsCookieMiddleware(req: Request, res: Response, next: NextFunction) {
export function checkAssetsCookieMiddleware(
req: { user: IJwtPayload } & Request,
res: Response,
next: NextFunction,
) {
if (req.path.startsWith('/assets') || req.path.startsWith('/locales')) {
const secret = process.env.INTERNAL_JWT_SECRET;
@ -24,7 +30,9 @@ export function checkAssetsCookieMiddleware(req: Request, res: Response, next: N
}
try {
jwt.verify(req.cookies.session, secret);
const jwtPayload = jwt.verify(req.cookies.session, secret);
req.user = jwtPayload as unknown as IJwtPayload;
} catch (error) {
logger.debug(error);
res.socket?.destroy();

View file

@ -0,0 +1,37 @@
import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
function deriveKey(secret: string): Buffer {
return createHash('sha256').update(secret).digest();
}
export function encryptUuid(uuid: string, secretKey: string): string {
const key = deriveKey(secretKey);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(uuid, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64url');
}
export function decryptUuid(data: string, secretKey: string): string | null {
try {
const key = deriveKey(secretKey);
const buf = Buffer.from(data, 'base64url');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const encrypted = buf.subarray(28);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
} catch {
return null;
}
}

View file

@ -1,10 +1,10 @@
import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
import { json } from 'express';
import cookieParser from 'cookie-parser';
import { createLogger } from 'winston';
import compression from 'compression';
import * as winston from 'winston';
import { nanoid } from 'nanoid';
import { json } from 'express';
import path from 'node:path';
import helmet from 'helmet';
import morgan from 'morgan';
@ -22,6 +22,7 @@ import { customLogFilter } from '@common/utils/filter-logs/filter-logs';
import { getRealIp } from '@common/middlewares/get-real-ip';
import { AppModule } from './app.module';
import { APP_CONFIG_ROUTE_WO_LEADING_PATH } from '@remnawave/subscription-page-types';
// const levels = {
// error: 0,
@ -76,6 +77,7 @@ async function bootstrap(): Promise<void> {
app.useStaticAssets(assetsPath, {
index: false,
dotfiles: 'ignore',
});
app.setBaseViewsDir(assetsPath);
@ -97,12 +99,15 @@ async function bootstrap(): Promise<void> {
app.use(
morgan(
':remote-addr - ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"',
{
skip: (req) => req?.url?.startsWith('/assets') ?? false,
},
),
);
const customSubPrefix = config.get<string>('CUSTOM_SUB_PREFIX') || '';
app.setGlobalPrefix(customSubPrefix, { exclude: ['assets/app-config-v2.json'] });
app.setGlobalPrefix(customSubPrefix, { exclude: [APP_CONFIG_ROUTE_WO_LEADING_PATH] });
if (customSubPrefix) {
logger.info('[CONFIG] CUSTOM_SUB_PREFIX: ' + customSubPrefix);

View file

@ -7,22 +7,29 @@ import {
TRequestTemplateTypeKeys,
} from '@remnawave/backend-contract';
import { GetJWTPayload } from '@common/decorators/get-jwt-payload';
import { ClientIp } from '@common/decorators/get-ip';
import { IJwtPayload } from '@common/constants';
import { SubpageConfigService } from './subpage-config.service';
import { RootService } from './root.service';
import { APP_CONFIG_ROUTE_WO_LEADING_PATH } from '@remnawave/subscription-page-types';
@Controller()
export class RootController {
private readonly logger = new Logger(RootController.name);
constructor(private readonly rootService: RootService) {}
constructor(
private readonly rootService: RootService,
private readonly subpageConfigService: SubpageConfigService,
) {}
@Get('assets/app-config-v2.json')
async getSubscriptionPageConfig() {
return await this.rootService.getSubscriptionPageConfig();
@Get(APP_CONFIG_ROUTE_WO_LEADING_PATH)
async getSubscriptionPageConfig(@GetJWTPayload() user: IJwtPayload, @Req() request: Request) {
return await this.subpageConfigService.getSubscriptionPageConfig(user.su, request);
}
@Get([':shortUuid', ':shortUuid/:clientType', ':shortUuid/config'])
@Get([':shortUuid', ':shortUuid/:clientType'])
async root(
@ClientIp() clientIp: string,
@Req() request: Request,

View file

@ -3,12 +3,13 @@ import { JwtModule } from '@nestjs/jwt';
import { getJWTConfig } from '@common/config/jwt/jwt.config';
import { SubpageConfigService } from './subpage-config.service';
import { RootController } from './root.controller';
import { RootService } from './root.service';
@Module({
imports: [JwtModule.registerAsync(getJWTConfig())],
controllers: [RootController],
providers: [RootService],
providers: [RootService, SubpageConfigService],
})
export class RootModule {}

View file

@ -14,6 +14,8 @@ import { TRequestTemplateTypeKeys } from '@remnawave/backend-contract';
import { AxiosService } from '@common/axios/axios.service';
import { sanitizeUsername } from '@common/utils';
import { SubpageConfigService } from './subpage-config.service';
@Injectable()
export class RootService {
private readonly logger = new Logger(RootService.name);
@ -26,6 +28,7 @@ export class RootService {
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly axiosService: AxiosService,
private readonly subpageConfigService: SubpageConfigService,
) {
this.isMarzbanLegacyLinkEnabled = this.configService.getOrThrow<boolean>(
'MARZBAN_LEGACY_LINK_ENABLED',
@ -122,13 +125,14 @@ export class RootService {
}
}
private async generateJwtForCookie(): Promise<string> {
private generateJwtForCookie(uuid: string | null): string {
return this.jwtService.sign(
{
sessionId: nanoid(32),
su: this.subpageConfigService.getEncryptedSubpageConfigUuid(uuid),
},
{
expiresIn: '1h',
expiresIn: '33m',
},
);
}
@ -171,16 +175,30 @@ export class RootService {
shortUuid: string,
): Promise<void> {
try {
const cookieJwt = await this.generateJwtForCookie();
const subscriptionDataResponse = await this.axiosService.getSubscriptionInfo(
clientIp,
shortUuid,
);
if (!subscriptionDataResponse.isOk || !subscriptionDataResponse.response) {
this.logger.error(`Get subscription info failed, shortUuid: ${shortUuid}`);
res.socket?.destroy();
return;
}
const subpageConfigResponse = await this.axiosService.getSubpageConfig(
shortUuid,
req.headers,
);
if (!subpageConfigResponse.isOk || !subpageConfigResponse.response) {
res.socket?.destroy();
return;
}
const subpageConfig = subpageConfigResponse.response;
if (subpageConfig.webpageAllowed === false) {
this.logger.log(`Webpage access is not allowed by Remnawave's SRR.`);
res.socket?.destroy();
return;
}
@ -192,10 +210,10 @@ export class RootService {
subscriptionData.response.ssConfLinks = {};
}
res.cookie('session', cookieJwt, {
res.cookie('session', this.generateJwtForCookie(subpageConfig.subpageConfigUuid), {
httpOnly: true,
secure: true,
maxAge: 3_600_000, // 1 hour
maxAge: 1_800_000, // 30 minutes
});
res.render('index', {
@ -333,8 +351,4 @@ export class RootService {
return true;
}
public async getSubscriptionPageConfig(): Promise<object> {
return this.axiosService.getCachedSubscriptionPageConfig();
}
}

View file

@ -0,0 +1,120 @@
import { exit } from 'node:process';
import { Request } from 'express';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Logger } from '@nestjs/common';
import {
SubscriptionPageRawConfigSchema,
TSubscriptionPageRawConfig,
} from '@remnawave/subscription-page-types';
import { decryptUuid, encryptUuid } from '@common/utils/crypt-utils';
import { AxiosService } from '@common/axios';
@Injectable()
export class SubpageConfigService implements OnApplicationBootstrap {
private readonly logger = new Logger(SubpageConfigService.name);
private readonly internalJwtSecret: string;
private readonly subpageConfigUuid: string;
private readonly subpageConfigMap: Map<string, TSubscriptionPageRawConfig> = new Map();
constructor(
private readonly configService: ConfigService,
private readonly axiosService: AxiosService,
) {
this.internalJwtSecret = this.configService.getOrThrow<string>('INTERNAL_JWT_SECRET');
this.subpageConfigUuid = this.configService.getOrThrow<string>('SUBPAGE_CONFIG_UUID');
}
public async onApplicationBootstrap(): Promise<void> {
const subscriptionPageConfigList = await this.fetchSubscriptionPageConfigList();
if (subscriptionPageConfigList.length === 0) {
this.logger.error('Subscription page config list is empty');
exit(1);
}
this.logger.log(`Found ${subscriptionPageConfigList.length} subscription page configs.`);
for (const config of subscriptionPageConfigList) {
const subscriptionPageConfig =
await this.axiosService.getSubscriptionPageConfigByUuid(config);
if (!subscriptionPageConfig.isOk || !subscriptionPageConfig.response) {
this.logger.error(`Subscription page config ${config} cannot be fetched`);
continue;
}
const parsedConfig = await SubscriptionPageRawConfigSchema.safeParseAsync(
subscriptionPageConfig.response.config,
);
if (!parsedConfig.success) {
this.logger.error(
`[ERROR] ${config} is not valid: ${JSON.stringify(parsedConfig.error)}`,
);
continue;
}
this.logger.log(`[OK] ${config}`);
this.subpageConfigMap.set(config, parsedConfig.data);
}
if (this.subpageConfigMap.size === 0) {
this.logger.error('[FAILED] At least one SubPage config must be valid!');
exit(1);
}
}
public async getSubscriptionPageConfig(
encryptedSubpageConfigUuid: string,
req: Request,
): Promise<object | void> {
const decryptedSubpageConfigUuid = decryptUuid(
encryptedSubpageConfigUuid,
this.internalJwtSecret,
);
if (!decryptedSubpageConfigUuid) {
req.socket?.destroy();
return;
}
const subpageConfig = this.subpageConfigMap.get(decryptedSubpageConfigUuid);
if (!subpageConfig) {
this.logger.error(`[FATAL] SubPage config ${decryptedSubpageConfigUuid} not found`);
req.socket?.destroy();
return;
}
return subpageConfig;
}
private async fetchSubscriptionPageConfigList(): Promise<string[]> {
const subscriptionPageConfigList = await this.axiosService.getSubscriptionPageConfigList();
if (!subscriptionPageConfigList.isOk || !subscriptionPageConfigList.response) {
this.logger.error('Subscription page config list cannot be fetched');
return [];
}
return subscriptionPageConfigList.response.configs.map((config) => config.uuid);
}
public getEncryptedSubpageConfigUuid(subpageConfigUuidFromRemnawave: string | null): string {
let uuidToEncrypt: string;
const isDefaultUuid = this.subpageConfigUuid === '00000000-0000-0000-0000-000000000000';
if (isDefaultUuid && subpageConfigUuidFromRemnawave) {
uuidToEncrypt = subpageConfigUuidFromRemnawave;
} else {
uuidToEncrypt = this.subpageConfigUuid;
}
return encryptUuid(uuidToEncrypt, this.internalJwtSecret);
}
}

View file

@ -1,12 +1,12 @@
{
"name": "@remnawave/subscription-page",
"version": "6.4.2",
"version": "7.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@remnawave/subscription-page",
"version": "6.4.2",
"version": "7.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@gfazioli/mantine-spinner": "^2.3.9",
@ -17,7 +17,7 @@
"@mantine/notifications": "8.3.10",
"@mantine/nprogress": "8.3.10",
"@remnawave/backend-contract": "2.3.55",
"@remnawave/subscription-page-types": "0.0.6",
"@remnawave/subscription-page-types": "0.1.0",
"@tabler/icons-react": "^3.35.0",
"clsx": "^2.1.1",
"color-hash": "^2.0.2",
@ -1995,9 +1995,9 @@
}
},
"node_modules/@remnawave/subscription-page-types": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@remnawave/subscription-page-types/-/subscription-page-types-0.0.6.tgz",
"integrity": "sha512-eqr0F/TiRHOjI5MMGK3GGcpkeACCejSP4+8aH/ip1ZNxW6iJTaVCLxDbvEcPKx1PaP3BVtsMEiEmlgep0sEBJg==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@remnawave/subscription-page-types/-/subscription-page-types-0.1.0.tgz",
"integrity": "sha512-1jJUT49EP+BcE0Vx8Vx8A4TdlO/vsBgoxljhyN5hp5PalcTM0WOhDpPIlyAzlqryI5b4FFJ6LEjAnbQ48Nd25Q==",
"license": "AGPL-3.0-only",
"dependencies": {
"zod": "3.25.76"

View file

@ -2,7 +2,7 @@
"name": "@remnawave/subscription-page",
"private": false,
"type": "module",
"version": "6.4.2",
"version": "7.0.0",
"license": "AGPL-3.0-only",
"author": "REMNAWAVE <github.com/remnawave>",
"homepage": "https://github.com/remnawave",
@ -37,7 +37,7 @@
"@mantine/notifications": "8.3.10",
"@mantine/nprogress": "8.3.10",
"@remnawave/backend-contract": "2.3.55",
"@remnawave/subscription-page-types": "0.0.6",
"@remnawave/subscription-page-types": "0.1.0",
"@tabler/icons-react": "^3.35.0",
"clsx": "^2.1.1",
"color-hash": "^2.0.2",
@ -127,4 +127,4 @@
"inquirer": "9.3.5"
}
}
}
}

View file

@ -8,6 +8,7 @@ import { LoadingScreen } from '@shared/ui'
import { MainPageComponent } from '../components/main.page.component'
import { useMediaQuery } from '@mantine/hooks'
import {
APP_CONFIG_ROUTE_LEADING_PATH,
SubscriptionPageRawConfigSchema,
TSubscriptionPageRawConfig
} from '@remnawave/subscription-page-types'
@ -31,7 +32,7 @@ export const MainPageConnector = () => {
const fetchConfig = async () => {
try {
const tempConfig = await ofetch<unknown>(
`/assets/app-config-v2.json?v=${Date.now()}`,
`${APP_CONFIG_ROUTE_LEADING_PATH}?v=${Date.now()}`,
{
parseResponse: (response) => JSON.parse(response)
}