Files
3x-ui/frontend/src/models/dbinbound.ts
T
mrchatam 6f40a75909 feat(inbound): excludeFromSub hides links without disabling (#6463)
* feat(inbound): excludeFromSub hides links without disabling

Add a per-inbound flag that omits subscription output while keeping the
inbound enabled for Xray, auth, and traffic accounting. Fixes #6435.

* fix(inbound): excludeFromSub review follow-ups

gofumpt model.go, sync docs OpenAPI, keep excludeFromSub master-authored
on node mirror, and exercise the legacy add-column migration path in tests.

* fix(sub): keep excluded inbounds' clients in the usage header

The excludeFromSub filter sat in getInboundsBySubId's SQL, so an excluded
inbound's clients never reached seenEmails in the raw, Clash or JSON
renderer. A client that lives only on a hidden inbound (one client per
inbound sharing a subId) dropped out of the Subscription-Userinfo usage,
quota and expiry and out of the info-node state, while the inbound kept
serving it and counting its traffic.

The query returns every enabled inbound again; each renderer skips an
excluded inbound's links but still counts its clients, the same rule the
Clash renderer already applies to external links it cannot express.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-09-27 12:26:37 +02:00

242 lines
5.3 KiB
TypeScript

import dayjs, { type Dayjs } from 'dayjs';
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
import { Protocols } from '@/schemas/primitives';
export type RawJsonField = string | Record<string, unknown> | unknown[];
export interface ClientStats {
email: string;
up: number;
down: number;
total: number;
expiryTime: number;
enable?: boolean;
inboundId?: number;
reset?: number;
}
export interface FallbackParentRef {
masterId: number;
path: string;
}
export type DBInboundInit = Partial<{
id: number;
userId: number;
up: number;
down: number;
total: number;
remark: string;
enable: boolean;
expiryTime: number;
trafficReset: string;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
protocol: string;
settings: RawJsonField;
streamSettings: RawJsonField;
tag: string;
sniffing: RawJsonField;
clientStats: ClientStats[];
nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
excludeFromSub: boolean;
disableFlow: boolean;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
}>;
export function coerceInboundJsonField(value: unknown): Record<string, unknown> {
if (value == null) return {};
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== 'string') return {};
const trimmed = value.trim();
if (trimmed === '') return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return {};
} catch {
return {};
}
}
export class DBInbound {
id: number;
userId: number;
up: number;
down: number;
total: number;
remark: string;
enable: boolean;
expiryTime: number;
trafficReset: string;
trafficResetDay: number;
lastTrafficResetTime: number;
listen: string;
port: number;
protocol: string;
settings: RawJsonField;
streamSettings: RawJsonField;
tag: string;
sniffing: RawJsonField;
clientStats: ClientStats[];
nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
excludeFromSub: boolean;
disableFlow: boolean;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
private _clientStatsMap: Map<string, ClientStats> | null = null;
constructor(data?: DBInboundInit) {
this.id = 0;
this.userId = 0;
this.up = 0;
this.down = 0;
this.total = 0;
this.remark = '';
this.enable = true;
this.expiryTime = 0;
this.trafficReset = 'never';
this.trafficResetDay = 1;
this.lastTrafficResetTime = 0;
this.listen = '';
this.port = 0;
this.protocol = '';
this.settings = '';
this.streamSettings = '';
this.tag = '';
this.sniffing = '';
this.clientStats = [];
this.nodeId = null;
this.shareAddrStrategy = 'node';
this.shareAddr = '';
this.subSortIndex = 1;
this.excludeFromSub = false;
this.disableFlow = false;
this.originNodeGuid = '';
this.fallbackParent = null;
if (data == null) {
return;
}
ObjectUtil.cloneProps(this, data);
}
get totalGB(): number {
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
}
set totalGB(gb: number) {
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
}
get isVMess() {
return this.protocol === Protocols.VMESS;
}
get isVLess() {
return this.protocol === Protocols.VLESS;
}
get isTrojan() {
return this.protocol === Protocols.TROJAN;
}
get isSS() {
return this.protocol === Protocols.SHADOWSOCKS;
}
get isMixed() {
return this.protocol === Protocols.MIXED;
}
get isHTTP() {
return this.protocol === Protocols.HTTP;
}
get isWireguard() {
return this.protocol === Protocols.WIREGUARD;
}
get isAmneziawg() {
return this.protocol === Protocols.AMNEZIAWG;
}
get isHysteria() {
return this.protocol === Protocols.HYSTERIA;
}
get isTuic() {
return this.protocol === Protocols.TUIC;
}
get isTunnel() {
return this.protocol === Protocols.TUNNEL;
}
get address(): string {
let address = location.hostname;
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== '0.0.0.0') {
address = this.listen;
}
return address;
}
get _expiryTime(): Dayjs | null {
if (this.expiryTime === 0) {
return null;
}
return dayjs(this.expiryTime);
}
set _expiryTime(t: Dayjs | null | undefined) {
if (t == null) {
this.expiryTime = 0;
} else {
this.expiryTime = t.valueOf();
}
}
get isExpiry(): boolean {
return this.expiryTime < new Date().getTime();
}
invalidateCache(): void {
this._clientStatsMap = null;
}
toJSON(): Record<string, unknown> {
const out: Record<string, unknown> = { ...(this as unknown as Record<string, unknown>) };
delete out._clientStatsMap;
return out;
}
getClientStats(email: string): ClientStats | undefined {
if (!this._clientStatsMap) {
this._clientStatsMap = new Map();
if (Array.isArray(this.clientStats)) {
for (const stats of this.clientStats) {
if (stats && stats.email) {
this._clientStatsMap.set(stats.email, stats);
}
}
}
}
return this._clientStatsMap.get(email);
}
}