Commit Graph

2459 Commits

Author SHA1 Message Date
MHSanaei
8d45cd8c68 feat(frontend): protocol-leaf Zod schemas with discriminated unions
Stand up schemas/primitives (Port, Flow, Protocol, Sniffing) and per-protocol
leaf schemas for all 10 inbound and 13 outbound xray protocols. The leaves
omit any inner `protocol` literal — the discriminator lives at the parent
level so consumers narrow on `.protocol` without redundant projection. Wire
shape is preserved per protocol: vmess outbound stays in `vnext[]`, trojan
and shadowsocks outbound in `servers[]`, vless outbound flat, http/socks
outbound in `servers[].users[]`.

Cross-protocol atoms (port, flow, sniffing dest, protocol enum) live in
primitives. Protocol-specific enums (vmess security, ss method/network,
hysteria version, freedom domain strategy, dns rule action) stay with their
leaves. Tagged-wrapper `z.discriminatedUnion('protocol', [...])` composes
both InboundSettingsSchema and OutboundSettingsSchema; existing class-based
models in src/models/ are untouched and will be retired in Step 3 once the
golden-file safety net is in place.
2026-05-25 23:02:08 +02:00
MHSanaei
31845fa8f6 refactor(frontend): tighten HttpUtil generics from any to unknown
Switch the class-level default on Msg<T> and the per-method defaults on
HttpUtil.get/post/postWithModal from `any` to `unknown`, so callers that
don't pass an explicit T get a narrowed response that must be schema-
checked or type-cast before its shape is trusted.

Drops the four file-level eslint-disable comments these defaults
required. Fixes the nine direct `.obj.field` consumers that surfaced
(IndexPage, XrayMetricsModal, NordModal, WarpModal, LogModal,
VersionModal, XrayLogModal, CustomGeoSection) by giving each call site
the explicit T it should have had from the start — typically a small
ad-hoc shape, sometimes a string for the JSON-text-in-Msg.obj pattern
used by NordModal/WarpModal/Xray nord/warp endpoints.

PR3 of the planned Zod end-to-end rollout — schemas/inbound.ts and
schemas/client.ts loose() removal stays parked until the protocol
schemas land in Phase 3 to avoid silently dropping fields.
2026-05-25 19:51:39 +02:00
MHSanaei
7bd281d26d feat(codegen): Go-first tool emitting Zod schemas and TS types
Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:

  - zod.ts   shared Zod schemas keyed off `validate:` tags (ports get
             .min(1).max(65535), Inbound.protocol becomes a z.enum,
             Node.scheme too, etc.)
  - types.ts plain TS interfaces inferred from the same walk, so
             consumers can import Inbound without dragging Zod along

The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.

Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.

PR2 of the planned Zod end-to-end rollout.
2026-05-25 19:29:44 +02:00
MHSanaei
7fda988fb2 feat(backend): gate request bodies with go-playground/validator
Add a generic BindAndValidate helper in web/middleware that wraps gin's
content-aware binder with an explicit validator.Struct call and emits a
structured `entity.Msg{Obj: ValidationPayload{Issues...}}` on failure so
the frontend can map each issue to an i18n key.

Tag the user-facing fields on model.Inbound, model.Node, and
entity.AllSetting with the range/enum constraints they were previously
relying on hand-rolled CheckValid logic (or nothing) to enforce, and
wire the helper into the inbound/node/settings controllers that bind
those structs directly. Promotes validator/v10 from indirect to direct
require, plus six unit tests covering valid payloads, range violations,
enum violations, malformed JSON, in-place binding, and JSON-only strict
mode.

This is PR1 of a planned end-to-end Zod rollout — controllers using
local form structs (custom_geo, setEnable, fallbacks, client) keep
their existing handling and will be migrated as their schemas firm up.
2026-05-25 19:17:54 +02:00
MHSanaei
9cf35234a5 feat(frontend): schema-guard Inbound and Outbound form submits
The two largest forms in the panel send to the backend without ever
checking their own port range or required-ness. Schema-gate the
top-level fields so obviously bad payloads stop at the client.

InboundFormModal: InboundFormSchema (port 1-65535 int, non-empty
protocol, the rest of the keys present) runs as a safeParse just
before the HttpUtil.post in submit(). The 2000+ lines of protocol-
specific subform code stay untouched - that's a separate effort and
the existing per-protocol logic (e.g. canEnableStream, isFallbackHost)
already gates most of the structural correctness.

OutboundFormModal: OutboundTagSchema (trim + min 1) replaces the
hand-rolled `if (!ob.tag?.trim()) messageApi.error('Tag is required')`
check. The duplicateTag check stays inline because it needs the
existingTags prop.

Both schemas emit i18n keys for messages with a defaultValue fallback,
matching the pattern in BalancerFormModal and SettingsPage.
2026-05-25 18:10:24 +02:00
MHSanaei
4ecbb0e55f feat(frontend): block invalid settings saves with Zod pre-save check
Tighten AllSettingSchema with the actual valid ranges and patterns:

- webPort / subPort / ldapPort: integer 1-65535
- pageSize: integer 1-1000
- sessionMaxAge: integer >= 1
- tgCpu: integer 0-100 (percentage)
- subUpdates: integer 1-168 (hours)
- expireDiff / trafficDiff / ldapDefault*: non-negative integers
- webBasePath / subPath / subJsonPath / subClashPath: must start with /

The existing useAllSettings save path runs AllSettingSchema.partial()
through safeParse and logs drift without blocking. SettingsPage now
adds a stronger gate before the mutation: run the full schema against
the draft and, on failure, surface the first issue (field path +
message) via the existing messageApi.error so the user actually sees
what's wrong instead of silently sending bad data to the backend.

Use cases caught: port out of range, negative quota, sub path missing
leading slash, page size set to 0, tgCpu > 100.
2026-05-25 17:55:21 +02:00
MHSanaei
a3012daa8f feat(frontend): migrate five secondary form modals to Zod schemas
Apply the schema + safeParse-on-submit pattern (introduced for
ClientFormModal / ClientBulkAddModal) to five more forms:

- ClientBulkAdjustModal: ClientBulkAdjustFormSchema enforces 'at least
  one of addDays / addGB is non-zero' via .refine(), replacing the
  ad-hoc days+gb check.
- BalancerFormModal: BalancerFormSchema covers tag and selector
  required-ness; the duplicate-tag check stays inline since it needs
  the otherTags prop. Per-field validateStatus now reads from the
  parsed issues map.
- RuleFormModal: RuleFormSchema captures the form shape (no required
  fields - every property is optional by design). safeParse short-
  circuits if anything is structurally wrong.
- CustomGeoFormModal: CustomGeoFormSchema folds the regex alias rule
  and the http(s) URL validation (including URL parse) into the
  schema, replacing a 20-line validate() function.
- TwoFactorModal: TotpCodeSchema (z.string().regex(/^\d{6}$/)) drives
  both the disabled-state of the OK button and the safeParse gate
  before the TOTP comparison.

Schemas live alongside the matching API schemas:
- ClientBulkAdjustFormSchema in schemas/client.ts
- BalancerFormSchema / RuleFormSchema / CustomGeoFormSchema in schemas/xray.ts
- TotpCodeSchema in schemas/login.ts (next to LoginFormSchema)

No UX change for valid inputs.
2026-05-25 17:45:02 +02:00
MHSanaei
2d55b3b663 fix(vite): bypass es-toolkit CJS shim for recharts deep imports
The Nodes page (and any other recharts-using route) crashed in dev and
prod with TypeError: require_isUnsafeProperty is not a function.

Root cause: es-toolkit's package.json exports './compat/*' only via a
default condition pointing at the CJS shims under compat/<name>.js.
Those shims use a require_X.Y access pattern that Vite's optimizer
(Rolldown in Vite 8) and the production Rolldown build both mishandle,
losing the named-export accessor and calling the namespace object as
a function. recharts imports a dozen of these subpaths with default-
import syntax, so every chart path tripped the bug.

The matching ESM build at dist/compat/<category>/<name>.mjs is fine,
but it only carries a named export. Recharts uses default imports.

Plug a small Rollup-compatible plugin (enforce: 'pre') in front of
the resolver: any 'es-toolkit/compat/<name>' request becomes a virtual
module that imports the named symbol from the right .mjs file and
re-exports it as both default and named. The plugin is registered as
a top-level plugin (for the prod build) and via the new Vite 8
optimizeDeps.rolldownOptions.plugins (for the dev pre-bundler), so
both pipelines pick it up consistently.
2026-05-25 17:33:20 +02:00
MHSanaei
75b0a21987 chore(frontend): silence swagger-ui-react peer-dep warnings on React 19
swagger-ui-react@5.32.6 bundles three deps whose declared peer ranges
predate React 19:

  react-copy-to-clipboard@5.1.0 (peer 15-18)
  react-debounce-input@3.3.0     (peer 15-18, unmaintained)
  react-inspector@6.0.2          (peer 16-18)

For the first two, the actual code is React-19 compatible - only the
metadata is stale. Resolve via npm overrides:

- react-copy-to-clipboard bumped to ^5.1.1 (peer is open-ended >=15.3.0
  in that release).
- react-inspector bumped to ^9.0.0 (^8 was a broken publish per its own
  deprecation notice).
- react-debounce-input is wedged on 3.3.0 with no maintained successor
  on npm. Use the nested-override syntax to satisfy its react peer:

    "react-debounce-input": { "react": "^19.0.0" }

  That tells npm to use our React 19 for the package's peer dependency,
  which silences the warning without changing the package version.
2026-05-25 17:33:04 +02:00
MHSanaei
6bbc9f6769 feat(frontend): drive form validation from Zod schemas
NodeFormModal — full conversion to AntD Form.useForm with antdRule
on every required field. Inline field errors replace the single
'fillRequired' toast. testConnection now runs validateFields(['address','port'])
before sending.

ClientFormModal and ClientBulkAddModal — minimal conversion: keep the
existing useState-driven controlled-component pattern, but replace the
hand-rolled `if (!form.x)` checks with schema.safeParse(form). The
schema is the single source of truth for required-ness and types;
ClientCreateFormSchema layers on the create-only `inboundIds.min(1)` rule.

New schemas (in src/schemas/):
  NodeFormSchema (node.ts)
  ClientFormSchema / ClientCreateFormSchema (client.ts)
  ClientBulkAddFormSchema (client.ts)

Other 16+ form modals stay on the current pattern — the antdRule adapter
ships from the first Zod pass for opportunistic migration as forms are
touched.
2026-05-25 16:41:56 +02:00
MHSanaei
2cd2085b75 fix(vite): treat /panel/xray as SPA page, not API root
The dev-server bypass classified /panel/xray as an API path because
the PANEL_API_PREFIXES matcher did `stripped === prefix.replace(/\/$/, '')`,
which made the bare path collide with the SPA route of the same name
(see web/controller/xui.go: g.GET("/xray", a.panelSPA)).

On reload, /panel/xray got proxied to the Go backend instead of being
served by Vite. The backend returned the embedded built index.html
with hashed asset names that the dev server doesn't have, so every
asset 404'd.

Prefix-only match for trailing-slash entries fixes it: panel/xray/...
still routes to the API, but panel/xray itself reaches the SPA branch.
2026-05-25 16:30:59 +02:00
MHSanaei
c16fb93899 fix(frontend): allow null slices in client/summary schemas
Go's encoding/json emits nil []T as null, not []. The initial
ClientPageResponseSchema and ClientHydrateSchema rejected null
inboundIds / summary.online / summary.depleted / etc., causing
[zod] warnings on every empty list.

Add nullableStringArray / nullableNumberArray helpers that accept
null and transform to [] so consuming code keeps seeing arrays.
Mark ClientRecord.traffic and .reverse nullable too (reverse is
explicitly null in MarshalJSON when storage is empty).
2026-05-25 16:30:48 +02:00
MHSanaei
d00ddc3f58 feat(frontend): extend Zod validation to remaining query/mutation hooks
Adds Zod schemas for client/inbound/xray/node-probe endpoints and wires
useNodeMutations, useClients, useInbounds, useXraySetting, useDatepicker
through parseMsg. Drops the duplicated per-file ApiMsg<T> interfaces and
the local ClientRecord / OutboundTrafficRow / XraySettingsValue / DefaultsPayload
declarations in favour of schema-inferred types re-exported from the
new src/schemas/ modules.

API boundary now validates: clients list/paged, clients onlines,
clients lastOnline, clients get/hydrate, inbounds slim, inbounds get,
inbounds options, defaultSettings, xray config, xray outbounds traffic,
xray testOutbound, xray getXrayResult, getDefaultJsonConfig, nodes probe,
nodes test. Mutation responses that consume obj (bulkAdjust, delDepleted,
nodes probe / test) get response validation; pass-through mutations stay
agnostic. NodeFormModal type-aligned to Msg<ProbeResult>.
2026-05-25 16:14:00 +02:00
MHSanaei
6846fac1cc feat(frontend): add Zod runtime validation at API boundary
Introduces Zod 4 schemas for response validation on the three highest-traffic
endpoints (server/status, nodes/list, setting/all) and a Zod->AntD form rule
adapter, replacing the duplicated per-file ApiMsg<T> interfaces. Validation
runs safeParse with console.warn + raw-payload fallback so backend drift never
breaks the UI for users.

Login form switches to schema-driven rules as the proof-of-life for the
adapter. Class-based models stay untouched; remaining query/mutation hooks
and form modals will migrate in follow-ups.
2026-05-25 16:02:27 +02:00
MHSanaei
20edaee8ed refactor(frontend): port api-docs/endpoints to TypeScript
endpoints.js was the only remaining JS file under src/. It's a pure data
file describing every panel API surface for the in-panel Swagger docs;
scripts/build-openapi.mjs reads it at build time to emit
public/openapi.json.

Convert it to endpoints.ts with explicit interfaces:
  HttpMethod, ParamLocation, ParamType,
  EndpointParam, Endpoint, SubscriptionHeader, Section

Type-checking surfaced shapes the .js had silently accepted:
  - 'in' values beyond plain 'body' — 'body (form)', 'body (json)',
    'body (multipart)' for non-JSON request bodies
  - 'type' arrays — 'integer[]', 'object[]'
  - Subscription section's subHeader documenting response headers
All four are now part of the union types so the existing data type-checks.

Dead exports removed:
  - safeInlineHtml — unused since the docs page switched to Swagger UI
  - methodColors — unused

Build pipeline:
  - scripts/build-openapi.mjs imports endpoints.ts directly
  - gen:api runs via Node 22's native --experimental-strip-types; no
    tsx/ts-node dependency added
  - --disable-warning=ExperimentalWarning silences just the strip-types
    notice while keeping deprecation warnings intact
2026-05-25 15:29:26 +02:00
Sanaei
dc37f9b731 Migrate frontend models/api/utils to TypeScript and modernize AntD theming (#4563)
* refactor(frontend): port api/* and reality-targets to TypeScript

Phase 1 of the JS→TS migration: convert three small, isolated files
(axios-init, websocket, reality-targets) to typed sources so future
phases can lean on their interfaces.

- api/axios-init.ts: typed CSRF cache, interceptors, request retry
- api/websocket.ts: typed listener map, message envelope guard,
  reconnect timer
- models/reality-targets.ts: RealityTarget interface, readonly list
- env.d.ts: minimal qs module shim (stringify/parse)
- consumers: drop ".js" extension from @/api imports

* refactor(frontend): port utils/index to TypeScript

Phase 2 of the JS→TS migration: convert the 858-line utility module
that 30+ pages and hooks depend on.

- Msg<T = any> generic with success/msg/obj shape preserved
- HttpUtil get/post/postWithModal generic over response shape
- RandomUtil, Wireguard, Base64 fully typed
- SizeFormatter/CPUFormatter/TimeFormatter/NumberFormatter typed
- ColorUtils.usageColor returns 'green'|'orange'|'red'|'purple' union
- LanguageManager.supportedLanguages readonly typed
- IntlUtil.formatDate/formatRelativeTime accept null/undefined
- ObjectUtil.clone/deepClone/cloneProps/equals kept as `any`-shaped
  to preserve the prior JS contract used by class-instance callers
  (AllSetting.cloneProps(this, data), etc.)

* refactor(frontend): port models/outbound to TypeScript (hybrid typing)

Phase 4 of the JS→TS migration: rename outbound.js to outbound.ts and
make it compile under strict mode with a minimal hybrid type pass.

- Enum-like constants kept as typed objects (Protocols, SSMethods, …)
- Top-level DNS helpers strictly typed
- CommonClass gets [key: string]: any so all subclasses can keep their
  loose this.foo = bar assignments without per-field declarations
- Constructor / fromJson / toJson signatures typed as any to preserve
  the prior JS contract used by consumers and parsers
- Outbound declares static fields for the dynamically-attached Settings
  subclasses (Settings, FreedomSettings, VmessSettings, …)
- urlParams.get() results that feed parseInt now use the non-null
  assertion since the surrounding has() check already guards them
- File-level eslint-disable for no-explicit-any/no-var/prefer-const to
  keep the JS-derived code building without churn

* refactor(frontend): port models/inbound to TypeScript (hybrid typing)

Phase 5 of the JS→TS migration. Same hybrid approach as outbound.ts:
constants typed strictly, classes get [key: string]: any from
XrayCommonClass, constructor / fromJson / toJson signatures use any.

- XrayCommonClass gains [key: string]: any plus typed static helpers
  (toJsonArray, fallbackToJson, toHeaders, toV2Headers)
- TcpStreamSettings/TlsStreamSettings/RealityStreamSettings/Inbound
  declare static fields for their dynamically-attached subclasses
  (TcpRequest, TcpResponse, Cert, Settings, ClientBase, Vmess/VLESS/
  Trojan/Shadowsocks/Hysteria/Tunnel/Mixed/Http/Wireguard/TunSettings)
- All gen*Link, applyXhttpExtra*, applyExternalProxyTLS*, applyFinalMask*
  and related helpers explicitly any-typed
- Constructor positional client-args (email, limitIp, totalGB, …) typed
  as optional any across Vmess/VLESS/Trojan/Shadowsocks/Hysteria.VMESS|
  VLESS|Trojan|Shadowsocks|Hysteria
- File-level eslint-disable for no-explicit-any/prefer-const/
  no-case-declarations/no-array-constructor to silence churn without
  changing behavior

* refactor(frontend): port models/dbinbound to TypeScript

Phase 6 — final phase of the JS→TS migration. Frontend src/ no
longer contains any *.js files.

- DBInbound declares all fields explicitly (id, userId, up, down,
  total, …, nodeId, fallbackParent) with proper types
- _expiryTime getter/setter typed against dayjs.Dayjs
- coerceInboundJsonField takes unknown, returns any
- Private cache fields (_cachedInbound, _clientStatsMap) declared
- Consumers (InboundFormModal, InboundsPage, useInbounds): drop ".js"
  extension from @/models/dbinbound imports

* refactor(frontend): drop .js extensions from TS-resolved imports

Cleanup after the JS→TS migration:

- All consumers that imported @/models/{inbound,outbound,dbinbound}.js
  now drop the .js extension (TS module resolution lands on the .ts
  file automatically)
- eslint.config.js: remove the **/*.js block since the only remaining
  JS file under src/ is endpoints.js (build-script consumed only) and
  js.configs.recommended already covers it correctly

* refactor(frontend): tighten inbound.ts cleanup wins

Checkpoint before the full any → typed pass:
- Wrap 15 case bodies in braces (no-case-declarations)
- Convert 14 let → const in genLink helpers (prefer-const)
- new Array() → [] for shadowsocks passwords (no-array-constructor)
- XrayCommonClass: HeaderEntry, FallbackEntry, JsonObject interfaces;
  fromJson/toV2Headers/toHeaders typed against them; static methods
  return JsonObject / HeaderEntry[] instead of any
- Reduce file-level eslint-disable scope from 4 rules to just
  no-explicit-any (the only one still needed)

* refactor(frontend): drop eslint-disable from models/dbinbound

Replace `any` with explicit domain types:
- `coerceInboundJsonField` returns `Record<string, unknown>` (settings/streamSettings/sniffing are always objects).
- Add `RawJsonField`, `ClientStats`, `FallbackParentRef`, `DBInboundInit` types.
- `_cachedInbound: Inbound | null`, `toInbound(): Inbound`.
- `getClientStats(email): ClientStats | undefined`.
- `genInboundLinks(): string` (matches actual return from Inbound.genInboundLinks).
- Constructor now accepts `DBInboundInit`.

* refactor(frontend): drop eslint-disable from InboundsPage

Type all callbacks against DBInbound from @/models/dbinbound:
- state setters use DBInbound | null
- helpers (projectChildThroughMaster, checkFallback, findClientIndex,
  exportInboundLinks, etc.) take DBInbound
- drop `(dbInbounds as any[])` casts; useInbounds already returns DBInbound[]
- introduce ClientMatchTarget for findClientIndex's `client` param
- tighten DBInbound.clientStats to ClientStats[] (default [])
- single boundary cast at <InboundList onRowAction=> to bridge
  InboundList's narrower DBInboundRecord (cleanup belongs with InboundList)

* refactor(frontend): drop file-level eslint-disable from utils/index

- ObjectUtil.clone/deepClone become generic <T>
- cloneProps/delProps accept `object` (cast internally to AnyRecord)
- equals accepts `unknown` with proper narrowing
- ColorUtils.usageColor narrows data/threshold to `number`; total widened
  to `number | { valueOf(): number } | null | undefined` so Dayjs works
- Utils.debounce replaces `const self = this` with lexical arrow
  closure (no-this-alias clean)
- InboundList._expiryTime narrowed from `unknown` to `{ valueOf(): number } | null`
- Single-line eslint-disable remains on `Msg<T = any>` and HttpUtil
  generic defaults (idiomatic API envelope; changing default to unknown
  cascades through 34 consumer files)

* refactor(frontend): drop eslint-disable from OutboundFormModal field section

Replace `type OB = any` with `type OB = Outbound`. Body code still
sees protocol fields as `any` via Outbound's inherited [key: string]: any
index signature (CommonClass) — that escape hatch will narrow as
Phase 6 tightens outbound.ts itself.

The intentional `// eslint-disable-next-line` on `useRef<any>(null)`
at line 72 stays — out of scope per plan.

* refactor(frontend): drop file-level eslint-disable from InboundFormModal

Add minimal local interfaces for protocol-specific shapes the form reads:
- StreamLike, TlsCert, VlessClient, ShadowsocksClient, HttpAccount,
  WireguardPeer (replace with real exports from inbound.ts as Phase 7
  exports them).
- Props typed as DBInbound | null + DBInbound[].
- Drop unnecessary `(Inbound as any).X`, `(RandomUtil as any).X`,
  `(Wireguard as any).X`, `(DBInbound as any)(...)` casts — they are
  already typed classes; only `Inbound.Settings`/`Inbound.HttpSettings`
  remain `any` via static field on Inbound (will tighten in Phase 7).
- inboundRef/dbFormRef retain single-line `// eslint-disable-next-line`
  for `useRef<any>(null)` — nullable narrowing across ~30 callsites
  exceeds Phase 5 scope.
- payload locals typed Record<string, unknown>; setAdvancedAllValue
  parses JSON into a narrowed object instead of `let parsed: any`.

* refactor(frontend): narrow outbound.ts eslint-disable to no-explicit-any only

- Fix all 36 prefer-const violations: convert never-reassigned `let` to
  `const`; for mixed-mutability destructuring (fromParamLink,
  fromHysteriaLink) split into separate `const`/`let` declarations
  by index instead of destructuring.
- Fix both no-var violations: `var stream` / `var settings` → `let`.
- File still carries `/* eslint-disable @typescript-eslint/no-explicit-any */`
  because tightening 223 `any` uses requires removing CommonClass's
  `[key: string]: any` escape hatch and reshaping ~30 dynamically-attached
  subclass patterns into named classes — multi-hour architectural work
  tracked as Phase 7's twin for outbound.

* refactor(frontend): align sub page chrome with login + AntD defaults

- Theme + language buttons now both use AntD `<Button shape="circle"
  size="large" className="toolbar-btn">` with TranslationOutlined and
  the SVG theme icon — identical hover/border behaviour.
- Language popover content switched from hand-rolled `<ul.lang-list>`
  to AntD `<Menu mode="vertical" selectable />`; gains native
  hover/keyboard nav + active highlight.
- Drop `.info-table` `!important` border overrides (8 selectors) so
  Descriptions inherits the AntD theme border colour.
- Drop `.qr-code` padding/background/border-radius overrides; only
  `cursor: pointer` remains (QRCode handles padding/bg itself).
- Remove now-unused `.theme-cycle`, `.lang-list`, `.lang-item*`,
  `.lang-select`, `.settings-popover` rules.

* refactor(frontend): drop CustomStatistic wrapper, move overrides to theme tokens

- Delete `<CustomStatistic>` (a pass-through wrapper over <Statistic>)
  and its unscoped global `.ant-statistic-*` CSS overrides; consumers
  (IndexPage, ClientsPage, InboundsPage, NodesPage) now import AntD
  `<Statistic>` directly.
- Add Statistic component tokens to ConfigProvider so the title (11px)
  and content (17px) font sizes still apply, without `!important`
  global selectors.
- Move dark / ultra-dark card border colours from `body.dark .ant-card`
  + `html[data-theme='ultra-dark'] .ant-card` selectors into Card
  `colorBorderSecondary` tokens; page-cards.css now only carries the
  custom radius/shadow/transition that has no token equivalent.
- Simplify XrayStatusCard badge: remove the custom `xray-pulse` dot
  keyframe and per-state ring-colour overrides; AntD `<Badge
  status="processing" color={…}>` already pulses the ring in the same
  colour, no extra CSS needed.

* refactor(frontend): modernize login page with AntD primitives

- Theme cycle button switched from `<button.theme-cycle>` + custom CSS
  to AntD `<Button shape="circle" className="toolbar-btn">` (matches
  sub page chrome already established).
- Theme icons switched from hand-rolled inline SVG (sun, moon,
  moon+star) to AntD `<SunOutlined />`, `<MoonOutlined />`,
  `<MoonFilled />` for the three light / dark / ultra-dark states.
- Language popover content switched from `<ul.lang-list>` +
  `<button.lang-item>` to AntD `<Menu mode="vertical" selectable />`
  with `selectedKeys=[lang]`; native hover / keyboard nav / active
  highlight come for free.
- Drop CSS for `.theme-cycle`, `.lang-list`, `.lang-item*` (now unused).
  `.toolbar-btn` retained since it sizes both circular buttons.

* refactor(frontend): switch sub page theme icons to AntD primitives

Replace the three hand-rolled SVG theme icons (sun, moon, moon+star)
with AntD `<SunOutlined />`, `<MoonOutlined />`, `<MoonFilled />`
for the light / dark / ultra-dark states. Switch the theme `<Button>`
to use the `icon` prop instead of children so it renders the same
way as the language button. Drop `.toolbar-btn svg` CSS — no longer
needed once the icon comes from AntD.

* refactor(frontend): drop !important overrides from pages CSS (Clients + Log modals + Settings tabs)

- ClientsPage: pagination size-changer `min-width !important` removed;
  the 3-level selector specificity already beats AntD's defaults.
  Scope `body.dark .client-card` to `.clients-page.is-dark .client-card`
  (avoid leaking into other pages).
- LogModal + XrayLogModal: move the mobile full-screen tweaks
  (`top: 0`, `padding-bottom: 0`, `max-width: 100vw`) from `!important`
  class rules to the Modal's `style` prop; keep `.ant-modal-content`
  / `.ant-modal-body` overrides as plain CSS via the className.
- SubscriptionFormatsTab: drop `display: block !important` on
  `.nested-block` — div is already block by default.
- TwoFactorModal: drop `padding/background/border-radius !important`
  on `.qr-code`; AntD QRCode handles those itself.

* refactor(frontend): scope dark overrides and switch list borders to AntD CSS variables

Scope page-level dark overrides:
- inbounds/InboundList: scope `.ant-table` border-radius rules and the
  mobile @media `.ant-card-*` tweaks to `.inbounds-page` (were global
  and leaked into other pages); scope `.inbound-card` dark variant to
  `.inbounds-page.is-dark`.
- nodes/NodeList: scope `.node-card` dark to `.nodes-page.is-dark`.
- xray/RoutingTab, OutboundsTab: scope `.rule-card`, `.criterion-chip`,
  `.criterion-more`, `.address-pill` dark to `.xray-page.is-dark`.

Modernize list borders to use AntD CSS vars instead of body.dark forks:
- index/BackupModal, PanelUpdateModal, VersionModal: replace
  hard-coded `rgba(5,5,5,0.06)` + `body.dark`/`html[data-theme]`
  override pairs with `var(--ant-color-border-secondary)`; replace
  custom text colours with `var(--ant-color-text)` /
  `var(--ant-color-text-tertiary)`.
- xray/DnsPresetsModal: same border-color treatment.
- xray/NordModal, WarpModal: collapse `.row-odd` light + `body.dark`
  pair into a single neutral `rgba(128,128,128,0.06)` that works on
  both themes; scope under `.nord-data-table` / `.warp-data-table`.

* refactor(frontend): switch shared components CSS to AntD CSS variables

Replace body.dark / html[data-theme] forks with AntD CSS variables
in shared components (work in both light and dark, scale to ultra):
- SettingListItem: borders + text colours via
  `--ant-color-border-secondary`, `--ant-color-text`,
  `--ant-color-text-tertiary`.
- InputAddon: bg/border/text via `--ant-color-fill-tertiary`,
  `--ant-color-border`, `--ant-color-text`.
- JsonEditor: host border/bg via `--ant-color-border`,
  `--ant-color-bg-container`; focus border via `--ant-color-primary`.
- Sparkline (SVG): grid/text colours via `--ant-color-text*`
  and `--ant-color-border-secondary`; only the tooltip drop-shadow
  retains a body.dark fork (filter opacity needs explicit value).

* refactor(frontend): swap custom Sparkline SVG for Recharts AreaChart

Replace the 368-line hand-rolled SVG sparkline (with manual
ResizeObserver, gradient/shadow/glow filters, grid + ticks + tooltip,
custom Y-axis label thinning) with a thin Recharts `<AreaChart>`
wrapper that keeps the same prop API.

- Preserved props: data, labels, height, stroke, strokeWidth,
  maxPoints, showGrid, fillOpacity, showMarker, markerRadius,
  showAxes, yTickStep, tickCountX, showTooltip, valueMin, valueMax,
  yFormatter, tooltipFormatter.
- Dropped: `vbWidth`, `gridColor`, `paddingLeft/Right/Top/Bottom` —
  Recharts' ResponsiveContainer handles width, and margins are wired
  to whether axes are visible. Removed the unused `vbWidth` prop from
  SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel callsites.
- Tooltip, grid, and axis text now use AntD CSS variables for
  automatic light/dark adaptation; replaced the SVG body.dark forks
  in Sparkline.css with a single 5-line stylesheet.
- Bundle: vendor +~100KB gzip (Recharts + its d3 deps), trade-off
  for less custom chart code to maintain and a more standard API
  for future charts (multi-series, brush, etc.).

* build(frontend): split Recharts + d3 deps into vendor-recharts chunk

Pulls Recharts (~75KB gzip) and its d3-shape/array/color/path/scale
+ victory-vendor deps out of the catch-all vendor chunk so they
load on demand on the three pages that use Sparkline
(SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) and cache
independently from the rest of the panel JS.

* refactor(frontend): drop body.dark forks in favor of AntD CSS variables

- ClientInfoModal/InboundInfoModal: link-panel-text and link-panel-anchor now use
  var(--ant-color-fill-tertiary) and color-mix on --ant-color-primary, removing
  the body.dark light/dark background pair.
- InboundFormModal: advanced-panel uses --ant-color-border-secondary and
  --ant-color-fill-quaternary; body.dark/html[data-theme='ultra-dark'] pair gone.
- CustomGeoSection: custom-geo-count, custom-geo-ext-code, custom-geo-copyable:hover
  use --ant-color-fill-tertiary/-secondary; body.dark forks gone.
- SystemHistoryModal: cpu-chart-wrap collapsed from three theme-specific gradients
  into one using color-mix on --ant-color-primary and --ant-color-fill-quaternary.
- page-cards.css: body.dark / html[data-theme='ultra-dark'] selectors renamed to
  page-scoped .is-dark / .is-dark.is-ultra, keeping the same shadow tuning but
  consistent with the page-scoping convention used elsewhere.

* refactor(sidebar): modernize AppSidebar with AntD CSS variables and icons

- Replace hardcoded rgba(0,0,0,X) colors with var(--ant-color-text)
  and var(--ant-color-text-secondary) so light/dark adapt automatically.
- Replace rgba(128,128,128,0.15) borders with var(--ant-color-border-secondary)
  and rgba(128,128,128,0.18) backgrounds with var(--ant-color-fill-tertiary).
- Drop all body.dark/html[data-theme='ultra-dark'] color forks for
  .drawer-brand, .sider-brand, .drawer-close, .sidebar-theme-cycle,
  .sidebar-donate (CSS variables already adapt).
- Drop the body.dark Drawer background !important pair; AntD's
  colorBgElevated token from the dark algorithm handles it now.
- Replace inline sun/moon SVGs in ThemeCycleButton with AntD's
  SunOutlined/MoonOutlined/MoonFilled to match LoginPage/SubPage.
- Convert .sidebar-theme-cycle hover and the menu item selected/hover
  highlights from hardcoded #4096ff to color-mix on --ant-color-primary,
  keeping !important on menu rules to beat AntD's CSS-in-JS specificity.

* refactor(frontend): swap hardcoded AntD palette colors for CSS variables

The dot/badge/pill styles still hardcoded AntD's default palette values
(#52c41a, #1677ff, #ff4d4f, #fa8c16, #ff4d4f). Replace each with its
semantic --ant-color-* equivalent so they auto-adapt to any theme
customization through ConfigProvider.

- ClientsPage: .dot-green/.dot-blue/.dot-red/.dot-orange/.dot-gray now
  use --ant-color-success / -primary / -error / -warning / -text-quaternary.
  .bulk-count / .client-card / .client-card.is-selected backgrounds use
  color-mix on --ant-color-primary and --ant-color-fill-quaternary, which
  also let the body-dark .client-card fork go away.
- XrayMetricsModal: .obs-dot is-alive/is-dead and its pulse keyframe now
  build their box-shadow tint via color-mix on --ant-color-success and
  --ant-color-error instead of rgba literals.
- IndexPage: .action-update warning color uses --ant-color-warning.
- OutboundsTab: .outbound-card border, .address-pill background, and
  .mode-badge tint now use AntD CSS variables; the .xray-page.is-dark
  .address-pill fork is gone.
- InboundFormModal/InboundsPage/ClientBulkAddModal: drop the stale
  `, #1677ff`/`, #1890ff` fallbacks on var(--ant-color-primary), and
  switch .danger-icon to --ant-color-error.

The teal/cyan brand colors (#008771, #3c89e8, #e04141) used by traffic
and pill rows are intentionally kept hardcoded — they are brand-specific
shades, not AntD palette colors.

* refactor(frontend): swap neutral gray rgba literals for AntD CSS variables

Across 12 files the same neutral grays kept reappearing — rgba(128,128,128,
0.06|0.08|0.12|0.15|0.18|0.2|0.25) for borders, dividers, and subtle
backgrounds. Each maps cleanly to an AntD CSS variable that already
adapts to light/dark and to any theme customization through ConfigProvider:

- 0.12–0.18 borders → var(--ant-color-border-secondary)
- 0.2–0.25 borders → var(--ant-color-border)
- 0.06–0.08 backgrounds → var(--ant-color-fill-tertiary)
- 0.02–0.03 card surfaces → var(--ant-color-fill-quaternary)

Card surfaces (InboundList .inbound-card, NodeList .node-card) had a
light/dark fork pair — the variable covers both, so the .is-dark .card
override is gone.

RoutingTab .rule-card.drop-before/after used hardcoded #1677ff for the
inset focus shadow; replaced with var(--ant-color-primary) so reordering
indicators follow the theme primary.

ClientsPage bucketBadgeColor returned hex literals (#ff4d4f, #fa8c16,
#52c41a, rgba gray) for a Badge color prop. Switched to status="error"|
"warning"|"success"|"default" so the dot color now comes from AntD's
semantic palette directly.

* refactor(xray): collapse RoutingTab dark forks into AntD CSS variables

- .criterion-more bg light/dark fork → var(--ant-color-fill-tertiary)
- .xray-page.is-dark .rule-card and .criterion-chip overrides removed;
  the rules already use --bg-card and --ant-color-fill-tertiary that
  adapt to the theme on their own.

* refactor(frontend): inline style hex literals and Alert icon redundancy

- FinalMaskForm: five DeleteOutlined icons used rgb(255,77,79) inline;
  swap for var(--ant-color-error) so they follow theme customization.
- NodesPage: CheckCircleOutlined / CloseCircleOutlined statistic prefixes
  switch to var(--ant-color-success) / -error.
- NodeList: ExclamationCircleOutlined warning icons (two callsites) now
  use var(--ant-color-warning).
- BasicsTab: four <Alert type="warning"> blocks shipped a custom
  ExclamationCircleFilled icon styled to match the warning palette —
  exactly the icon and color AntD Alert renders for type="warning" by
  default. Replace the icon prop with showIcon and drop the now-unused
  ExclamationCircleFilled import.
- JsonEditor: focus-within box-shadow tint now uses color-mix on
  --ant-color-primary instead of an rgba(22,119,255,0.1) literal.

* refactor(logs): collapse log-container dark forks to AntD CSS variables

LogModal and XrayLogModal each had a body.dark fork that overrode the
log container's background, border-color, and text color in addition
to the --log-* severity tokens. Background/border/color all map cleanly
to var(--ant-color-fill-tertiary) / var(--ant-color-border) /
var(--ant-color-text) which already adapt to the theme, so only the
severity color tokens remain inside the dark/ultra-dark blocks.

* refactor(xray): drop stale --ant-primary-color fallbacks and hex literals

- RoutingTab .drop-before/.drop-after box-shadow: #1677ff → var(--ant-color-primary)
- OutboundFormModal .random-icon: drop the --ant-primary-color/#1890ff
  pair (the old AntD v4 token name with stale fallback) for the v6
  --ant-color-primary; .danger-icon hex #ff4d4f → var(--ant-color-error).
- XrayPage .restart-icon: same drop of the --ant-primary-color fallback.

These were all leftovers from the AntD v4 → v6 rename — the v6
--ant-color-primary is already populated by ConfigProvider, so the
fallback hex was dead code that would only trigger if AntD wasn't
mounted.

* refactor(frontend): consolidate margin utility classes into one stylesheet

Page CSS files each carried their own copies of the same atomic margin
utilities (.mt-4, .mt-8, .mb-12, .ml-8, .my-10, ...). The definitions
were identical everywhere they appeared, with each file holding only
the subset it happened to need.

Move all of them into a single styles/utils.css imported once from
main.tsx, and delete the per-page copies from InboundFormModal,
CustomGeoSection, PanelUpdateModal, VersionModal, BasicsTab, NordModal,
OutboundFormModal, and WarpModal. The classes are available globally
on the panel app; login.tsx and subpage.tsx entries do not consume any
of them so they stay untouched.

* refactor(frontend): consolidate shared page-shell rules into one stylesheet

Every panel page CSS file repeated the same wrapper boilerplate — the
--bg-page/--bg-card token triples for light/dark/ultra-dark, the
min-height + background root rule, the .ant-layout transparent reset,
the .content-shell transparent reset, and the .loading-spacer min-height.
That's ~30 identical lines duplicated across IndexPage, ClientsPage,
InboundsPage, XrayPage, SettingsPage, NodesPage, and ApiDocsPage.

Move all of it into styles/page-shell.css and import it once from
main.tsx alongside utils.css and page-cards.css. Each page CSS file
now only contains genuinely page-specific rules (content-area padding
overrides, page-specific tokens like ApiDocs's Swagger --sw-* set).

Also drop the per-page `import '@/styles/page-cards.css'` statements
from the 7 page tsx files now that main.tsx loads it globally.

Net: -211 deleted, +6 inserted in the touched files, plus the new
page-shell.css. .zero-margin (Divider override used by Nord/Warp
modals) folded into utils.css alongside the margin classes.

* refactor(frontend): move default content-area padding to page-shell.css

After page-shell.css landed, six of the seven panel pages still kept an
identical `.X-page .content-area { padding: 24px }` desktop rule, plus
three of them kept an identical `padding: 8px` mobile rule. Hoist both
defaults into page-shell.css under a single 6-page selector group and
delete the per-page copies.

What stays page-specific:
- IndexPage keeps its mobile override (padding 12px + padding-top: 64px
  for the fixed drawer handle clearance).
- ApiDocsPage keeps its tighter desktop padding (16px) and its own
  mobile padding-top: 56px.

Settings .ldap-no-inbounds also switches from #999 to
var(--ant-color-text-tertiary) for theme adaptation.

* refactor(frontend): hoist .header-row, .icons-only, .summary-card to page-shell.css

Settings and Xray pages both carried identical .header-row /
.header-actions / .header-info rules and an identical six-rule
.icons-only block that styles tabbed page navigation. Clients, Inbounds,
and Nodes all carried identical .summary-card padding rules with the
same mobile reduction. None of these are page-specific.

Consolidate:
- .header-row family → page-shell scoped to .settings-page, .xray-page
- .icons-only family → page-shell global (the class is a deliberate
  opt-in marker, no scope needed)
- .summary-card → page-shell scoped to .clients-page, .inbounds-page,
  .nodes-page (also fixes InboundsPage's missing scope — its rule was
  global and would have matched stray .summary-card uses elsewhere)

InboundsPage.css and NodesPage.css became empty after the move so the
files and their per-page imports are deleted.

* refactor(frontend): hoist .random-icon to utils.css

Three form modals each carried identical .random-icon styles (small
primary-tinted icon next to randomizable inputs):
  ClientBulkAddModal, InboundFormModal, OutboundFormModal

Single definition lives in utils.css now. ClientBulkAddModal.css was
just this one rule, so the file and its import are deleted along the way.

.danger-icon is left per file — the margin-left differs slightly
between InboundFormModal (6px) and OutboundFormModal (8px), so it
stays as a page-local rule rather than getting averaged into utils.css.

* refactor(frontend): hoist .danger-icon to utils.css and use it everywhere

InboundFormModal (margin-left 6px) and OutboundFormModal (margin-left
8px) each carried their own .danger-icon, and FinalMaskForm wrote the
same color/cursor/marginLeft trio inline five times. Unify on a single
.danger-icon in utils.css with margin-left: 8px — matching the more
generous OutboundFormModal value — and:
- Drop the per-file .danger-icon copies from InboundFormModal.css and
  OutboundFormModal.css.
- Replace the five inline style props in FinalMaskForm.tsx with
  className="danger-icon".

The visible change is a 2px wider gap to the right of the delete icons
on InboundFormModal's protocol/peer dividers.
2026-05-25 14:34:53 +02:00
Sanaei
19e88c4610 fix: address open bug reports (#4539, #4538, #4535, #4531, #4515) (#4545)
* fix: hash-storage panic on SIGHUP and seeder dup-key on cold restart (#4539)

Two bugs that combine into an unrecoverable crash loop after a user
enables the Telegram bot in settings on a fresh install.

1. CheckHashStorageJob.Run panics with a nil pointer dereference. The
   cron job is scheduled whenever settings say the bot is enabled, but
   the package-level hash storage is only initialized inside
   Tgbot.Start, which StartPanelOnly intentionally skips
   (startTgBot=false). Toggling the bot on via the panel triggers
   SIGHUP, the storage stays nil, and the cron fires 2 minutes later
   and panics, exiting 2.

2. seedClientsFromInboundJSON is not idempotent. The fresh-install
   early-return path recorded only UserPasswordHash + ApiTokensTable,
   never ClientsTable. After the admin adds clients via the panel
   (which writes to the clients table through SyncInbound), the next
   start runs the seeder for the first time, finds matching emails
   already in the table, and fails with SQLSTATE 23505 on
   idx_clients_email, turning the panic above into an unrecoverable
   crash loop on PostgreSQL.

Fixes:
- web/job/check_hash_storage.go: nil-check the storage before calling
  RemoveExpiredHashes.
- database/db.go: in the fresh-install early-return path, also record
  ClientsTable so the seeder never re-runs against panel-added data.
- database/db.go: hydrate seedClientsFromInboundJSON's byEmail cache
  from existing rows so it merges instead of inserting when a row with
  the same email already lives in the clients table.

Regression tests cover both paths.

Closes #4539

* fix(clients): preserve protocol-specific credentials across multi-inbound syncs (#4538)

fillProtocolDefaults only populates the credential relevant to the
inbound's protocol (c.ID for VLESS, c.Auth for Hysteria, c.Password
for Trojan/Shadowsocks). Each inbound's settings.clients JSON
therefore carries the same client with only one of those fields set.

SyncInbound's update path was unconditionally copying every credential
column from incoming to the existing clients row, so the second sync
(e.g. Hysteria after VLESS) would write UUID="" over a valid VLESS
UUID and Auth="" the other way around. The next GetXrayConfig then
emitted VLESS client entries with no "id" field, and xray-core
crashed on startup with "common/uuid: invalid UUID:".

Guard UUID/Password/Auth/Flow/Security/Reverse against empty
overwrites so each protocol's sync only writes the credentials it
actually owns. Other fields (LimitIP, TotalGB, Comment, etc.) keep
the existing copy-everything behavior so admins can still clear them
through the panel.

Regression test in client_sync_multiprotocol_test.go.

Closes #4538

* fix(expiry): show delayed-start countdown in subscribe and client info (#4535)

A client with "start after first use" expiry stores the duration as a
negative number of milliseconds (e.g. -86400000 = 1 day after first
connect). The clients page row already renders this correctly as
"Delayed start: 1d", but two other surfaces treated negative values as
zero and rendered them as unlimited:

- Subscription header: the index==0 / index>0 branches in subService,
  subClashService and subJsonService only carried ExpiryTime forward
  when > 0, so traffic.ExpiryTime stayed at zero and the header sent
  expire=0. Every imported client appeared to have no expiry, and the
  built-in subscribe page rendered the "unlimited" tag.

- ClientInfoModal: both the expiryLabel helper and the rendering check
  treated <= 0 as the "no expiry" branch, so the modal showed an
  infinity tag instead of "Delayed start: Nd".

Add subscriptionExpiryFromClient to map negative durations onto a
"now + |value|" timestamp so subscription clients see an actual expiry
they can count down from. Update ClientInfoModal's helper and render
to match the clients-page convention.

Regression test in subService_test.go covers the helper.

Refs #4535

* feat(clash): emit xhttp and httpupgrade transports in subscription (#4531)

applyTransport's switch only covered tcp/ws/grpc; xhttp and
httpupgrade inbounds fell through to the default branch and returned
false. buildProxy then returned a nil map and the inbound was dropped
from the Clash subscription. When the subscription only contained
xhttp/httpupgrade inbounds, the proxies list ended up empty and the
client saw a 404 (or an "Error!" body on older builds), then refused
to parse.

Add a case for each, mapping the inbound's stream settings onto the
Mihomo-format opts blocks:

  xhttp        -> xhttp-opts: { path, host, mode }
  httpupgrade  -> http-upgrade-opts: { path, headers: { Host } }

Host falls back to the headers map when the dedicated `host` field is
empty, matching the existing ws behavior.

Closes #4531

* fix(online): refresh online-clients list even when no WS frontend is connected (#4515)

XrayTrafficJob and NodeTrafficSyncJob both gated the entire
post-traffic-write block behind websocket.HasClients() to skip
expensive broadcasts when no browser is open. The block included the
RefreshOnlineClientsFromMap call that keeps the in-memory
p.onlineClients list current.

Several non-WS consumers read that same list:
- Telegram bot (tgbot.go calls p.GetOnlineClients in 3 places)
- REST GET /panel/api/onlines (returned to API callers)
- Internal alerts that check whether a client is online

When no browser was watching the dashboard, the list went stale and
stayed empty, so the bot reported "nobody online" and the onlines API
returned [] even when xray had active sessions.

Move RefreshOnlineClientsFromMap above the HasClients guard so the
in-memory list is always fresh. Only the actual BroadcastTraffic /
BroadcastClientStats / BroadcastOutbounds calls (and the
GetAllClientTraffics / GetInboundsTrafficSummary work that feeds them)
remain gated by HasClients.

Closes #4515

* fix: address copilot review on #4545

Two issues raised by the Copilot review:

1) subscriptionExpiryFromClient called time.Now() per invocation.
   Two clients with the same delayed-start duration normalized to
   timestamps a few milliseconds apart, so the aggregator's
   "if normalized != traffic.ExpiryTime" check tripped and the
   subscription header expire= dropped back to 0 — the exact bug
   the helper was meant to fix, just one client later.

   Take nowMs as a parameter; each of GetSubs / GetClash / GetConfig
   captures one timestamp per request and reuses it.

2) Guarding Flow against empty incoming values in SyncInbound
   prevented a user from ever clearing a VLESS flow via the panel.
   FlowOverride on client_inbounds is the per-inbound mechanism that
   already preserves flow correctly across protocols, so the guard
   on the shared clients.flow column is the wrong place.

   Drop the Flow guard, keep the rest (UUID/Password/Auth/Security/
   Reverse — none of which have a per-inbound override column).
   Adds a regression test that asserts clearing flow on the owning
   inbound makes ListForInbound return flow="".

   The existing cross-protocol test is rewritten to assert on the
   user-visible behavior (ListForInbound flow) instead of the shared
   clients.flow column.
2026-05-25 00:08:06 +02:00
MHSanaei
b196f481a8 chore(github): overhaul issue and PR templates
Bug, feature, and question templates now collect the triage signal the
maintainers usually have to ask for (install method, OS, area, reverse
proxy, logs, version). config.yml disables blank issues and points to
Wiki / existing issues / latest release from the picker.

PR template adds Summary/Why/Type/Areas/Testing/Breaking-changes
sections and a fuller checklist (build, tests, lint, typecheck, docs).
Renamed pull_request_template.yml -> .md to match GitHub's conventional
extension; the old .yml was being read as markdown anyway.
2026-05-24 22:14:28 +02:00
Maksim Alekseev
1f90d2a6ee feat(inbound): Advanced XHTTP and external TLS proxy settings (#4491)
*  Introduce extended XHTTP and external proxy settings

*  Add custom SNI for proxy

*  Add previous changes into React version of app

* fix(sub): isolate per-proxy tlsSettings during external-proxy iteration

cloneMap (Clash) is shallow and `newStream := stream` (JSON) is an alias,
so tlsSettings was shared across iterations. The new applyExternalProxyTLSToStream
mutates it, leaking one proxy's serverName/fingerprint/alpn into the next
(only overwritten when the next proxy explicitly sets the same field).

Add cloneStreamForExternalProxy: shallow clones the top-level stream plus
deep clones tlsSettings and tlsSettings.settings. Regression test locks
in that proxy B does not inherit proxy A's fingerprint/alpn when B leaves
them unset.
2026-05-24 21:54:26 +02:00
Sanaei
cfe1b25ca0 feat(frontend): TanStack Query + React Router migration & in-panel API docs (#4541)
* feat(frontend): introduce TanStack Query with status polling

Wires @tanstack/react-query into every entry and migrates useStatus to
useStatusQuery as the foundation for the multi-page MPA → SPA migration.

- QueryProvider wraps each entry inside ThemeProvider, with devtools gated
  on import.meta.env.DEV
- Shared queryClient: 30s staleTime, refetchOnWindowFocus, 1 retry
- useStatusQuery preserves the { status, fetched, refresh } shape so
  IndexPage swaps in without further changes
- refetchIntervalInBackground:false stops the 2s status poll when the
  panel tab is hidden, cutting idle traffic against the server

* feat(frontend): collapse panel pages into a single React Router SPA

Replaces the 7-entry MPA shell (index/clients/inbounds/nodes/settings/
xray/api-docs HTML files) with one main.tsx + createBrowserRouter. The
Go backend now serves the same index.html for every authenticated
panel route; React Router reads the URL and mounts the page from cache
on subsequent navigation — no more full reloads between tabs.

Frontend
- main.tsx: single bootstrap (setupAxios, i18n, ThemeProvider,
  QueryProvider, RouterProvider) replacing 7 near-duplicate entries
- routes.tsx: declarative router with lazy()-loaded pages, basename
  derived from window.X_UI_BASE_PATH so panels at /secret/panel work
- layouts/PanelLayout.tsx: shell mount-point for the WS → queryClient
  bridge so connection survives navigation
- api/websocketBridge.ts: subscribes the singleton WebSocketClient to
  queryClient and dispatches invalidate/outbounds events to cached
  queries (page-level useWebSocket handlers stay until Phase 3 hooks
  migrate)
- AppSidebar: navigates via useNavigate + useLocation instead of
  window.location.href; drops basePath/requestUri props
- Pages: drop the unused basePath/requestUri locals exposed only for
  the old sidebar

Build
- vite.config: 9 rollup inputs → 3 (index, login, subpage). Dev proxy
  bypass collapses /panel/* to index.html and skips API prefixes
- vendor-tanstack + vendor-router chunks added to manualChunks

Backend
- xui.go: 7 per-page HTML handlers → one panelSPA handler serving
  index.html for /, /inbounds, /clients, /nodes, /settings, /xray,
  /api-docs. The /panel/api, /panel/setting, /panel/xray sub-routers
  are untouched

* feat(frontend): migrate useNodes to TanStack Query

Splits the hand-rolled useNodes hook into useNodesQuery (server data +
NodeRecord type + derived totals) and useNodeMutations (add/update/del/
setEnable/probe/test). Mutations invalidate ['nodes'] on success, so
the list refreshes without each call awaiting a manual refresh().

NodesPage drops useWebSocket({ nodes: applyNodesEvent }) — the
WebSocket → query bridge now forwards the 'nodes' push to
setQueryData(['nodes', 'list']) once at the SPA root.

InboundsPage and the inbound form/list components import NodeRecord
from its new home next to the query hook.

* feat(frontend): migrate useAllSetting to TanStack Query

Replaces the hand-rolled fetch + dirty-tracking hook with useAllSettings
backed by useQuery + useMutation. The draft (current edits) is kept in
local state and reset whenever query.data lands. saveAll posts the
draft via a mutation; on success, invalidating ['settings'] refetches
and the useEffect resets the draft so saveDisabled flips back to true.

staleTime: Infinity prevents refetchOnWindowFocus from clobbering
in-flight edits — settings only change in response to this user's own
save.

setSpinning stays as a pass-through to a local flag so the existing
restartPanel flow in SettingsPage keeps showing its spinner.

* feat(frontend): route useInbounds fetches through TanStack Query

Rewrites useInbounds so its four server fetches (slim list, default
settings, online clients, last-online map) live in useQuery with
staleTime: Infinity. The in-place WS merge logic for traffic and
client_stats is preserved — applyTrafficEvent / applyClientStatsEvent
still mutate the locally-mirrored dbInbounds so the panel doesn't
refetch every 1-2 seconds when stats stream in.

refresh() becomes a thin invalidateQueries on the three list keys,
which mutations in the page already call after add/edit/del.

The bridge now forwards the WebSocket 'inbounds' push to
setQueryData(['inbounds', 'slim']), and InboundsPage drops its
useEffect(fetchDefaultSettings → refresh) plus the invalidate /
inbounds wiring on useWebSocket — both are owned by the bridge now.

* feat(frontend): migrate useClients to TanStack Query

Replaces 12 hand-rolled mutation callbacks and a tangle of useState +
useRef + useEffect with one useQuery (paged list) + nine useMutation
wrappers. The list query uses keepPreviousData so paging/filter
changes don't blank the table mid-fetch.

The setQuery shallow-compare logic is preserved for backward
compatibility with ClientsPage's effect that rebuilds the params on
every render. Internally setQuery only updates state when the params
actually differ — Query's queryKey equality handles the rest.

WS-driven applyTrafficEvent / applyClientStatsEvent now mutate the
query cache via setQueryData(['clients', 'list', currentParams]) so
per-second stats updates skip a full refetch. applyInvalidate is gone
from the hook — the bridge owns coarse 'clients' invalidation.

ClientsPage drops the invalidate handler from its useWebSocket
subscription; auxiliary queries (inboundOptions, defaults, onlines)
load via TanStack Query and are shared with useInbounds via the same
query keys.

* feat(frontend): route useXraySetting fetches through TanStack Query

Keeps the bidirectional xraySetting ↔ templateSettings editor sync and
the 1s dirty-tracking interval intact (those are local editor state,
not server data). All seven server calls move:

- config + traffic → useQuery on ['xray', 'config'] and
  ['xray', 'outboundsTraffic']
- saveAll → useMutation that invalidates the config query
- resetOutboundsTraffic → useMutation that invalidates the traffic
  query
- restartXray → useMutation (fires the restart, then reads the
  result string)
- resetToDefault → useMutation (fetch default config, push it into
  the editor via setTemplateSettings)

The WebSocket 'outbounds' event already lands in
keys.xray.outboundsTraffic() via the bridge, so XrayPage drops its
useWebSocket({ outbounds: applyOutboundsEvent }) wiring entirely and
the hook no longer exposes applyOutboundsEvent.

A useEffect seeds xraySetting / templateSettings / tags / test URL
from query data on first fetch and on every refetch, mirroring what
the original fetchAll() did.

* fix(frontend): restore per-route document titles in the SPA

When the multi-entry MPA collapsed into a single index.html, every
route inherited the static <title>3X-UI</title> from the shared shell,
so every panel page showed "hostname - 3X-UI" instead of the original
"hostname - Overview / Clients / Inbounds / ...".

usePageTitle reads the current pathname and rewrites document.title
on every navigation, matching the titles the deleted *.html files
used to carry. Mounted in PanelLayout so it covers all panel routes
without each page having to opt in.

The startup applyDocumentTitle() call in main.tsx is gone — the hook
sets the full "hostname - PageTitle" string itself.

* feat(api-docs): expose OpenAPI spec + render Swagger UI in panel

Replaces the hand-rolled API docs UI with industry-standard tooling so
external integrations (Postman, Insomnia, openapi-generator) can
consume the panel API without parsing endpoints.js by hand.

Generator
- frontend/scripts/build-openapi.mjs: walks the existing endpoints.js
  (still the single source of truth) and emits an OpenAPI 3.0.3 spec
  at frontend/public/openapi.json. Handles Gin :param → {param} path
  translation, body / query / path parameter splits, 200 + error
  response examples, and Bearer + cookie security schemes
- npm run build now runs gen:api before vite build, so the spec is
  always in sync with what's documented

Backend
- web/controller/dist.go exposes ServeOpenAPISpec which streams the
  embedded dist/openapi.json with a short Cache-Control. Public
  endpoint (no auth) so Postman can fetch it without first logging in
- web/web.go wires GET /panel/api/openapi.json before the auth-gated
  /panel/api router

Panel
- ApiDocsPage now renders swagger-ui-react fed by the basePath-aware
  openapi.json URL. Dark mode is overridden via CSS targeting the
  Swagger UI internals
- CodeBlock / EndpointRow / EndpointSection are gone; the swagger-ui
  vendor chunk (134 KB gzipped) only loads on this lazy route, not on
  every panel page
- vite.config: vendor-swagger manualChunk keeps the new dep out of
  the main vendor bundle

For Postman: import http://<panel>/panel/api/openapi.json. Everything
from /login + /panel/api/* shows up with auth, params, and examples.

* style(api-docs): dark/ultra theme for Swagger UI

Override every visual surface Swagger does not theme on its own:
opblocks, tables, model boxes, form inputs, code blocks, modals,
Servers dropdown, per-endpoint padlocks and expand chevrons. Replaces
Swagger's default light-arrow chevron on selects with a light-fill SVG
positioned at the corner so the dark background-color is visible.

Also disables deepLinking to silence the noisy v4 underscore warning;
not used in our panel.
2026-05-24 21:34:52 +02:00
MHSanaei
867a145979 feat(clients): add inbound filter + mobile page-size control
Filter bar gets an Inbound select next to Protocol — the dropdown is
narrowed to inbounds matching the chosen protocol (or shows everything
when no protocol is picked), with remark search inside the dropdown.
Choosing a protocol clears any inbound selection that no longer fits.

Server side, ClientPageParams gains an Inbound int and ListPaged runs a
clientMatchesInbound check after the protocol filter. The selection
persists in clientsFilterState localStorage alongside the existing
search/filter/protocol entries.

Mobile clients view also grows the AntD Pagination control that was
previously only on the desktop table, so page size / page navigation
are reachable from phones.
v3.1.0
2026-05-23 23:31:41 +02:00
MHSanaei
6185db586a fix(clients): drop tombstone gate that blocked re-import after delete
ClientService.Delete tombstones a just-deleted email for 90s to keep a
late node snapshot from resurrecting it. The same check was also gating
the create branch of SyncInbound — which silently dropped clients on any
legitimate re-add (delete inbound + re-import within 90s left the
clients table empty even though settings.clients carried the rows).

The snapshot-side caller in setRemoteTraffic already filters tombstoned
emails before handing the list to SyncInbound, so removing the duplicate
check inside SyncInbound preserves the protection where it's needed and
unblocks user-initiated re-imports.

While here, mirror the addInbound shape in importInbound (NodeID=0→nil
normalisation, early return on error, broadcastInboundsUpdate) and fan
out a notifyClientsChanged from add/del/update/import so an open Clients
page picks up settings.clients reconciliation without a manual refresh.
2026-05-23 23:05:43 +02:00
MHSanaei
4c71669815 fix(clients): match by email when client identifier is stale
DBs migrated from older versions where the same email lived in
multiple inbounds with different UUIDs/passwords/auths end up with one
merged ClientRecord but each inbound's settings.clients JSON still
carries its original protocol-specific identifier. Editing such a
client through /panel/api/clients/update/:email failed with
"empty client ID" because UpdateInboundClient couldn't locate the
entry by the ClientRecord's identifier.

When the primary lookup misses, fall back to resolving the
ClientRecord by the supplied identifier and matching the inbound
entry by email. The update then proceeds and the inbound JSON
converges to the merged identifier.
2026-05-23 21:34:55 +02:00
Sanaei
c6123f9628 fix(frontend): resolve lazy chunk URLs against runtime base path (#4505)
* fix(frontend): reload page on Vite chunk preload error after upgrade

After a panel upgrade the embedded dist/ ships with new hashed chunk
filenames, so SPA tabs loaded before the upgrade hold references to
chunks that no longer exist on the server and lazy modals 404. Hook
`vite:preloadError` and force one full reload (guarded by a session
flag) so the browser picks up the new index.html.

* Revert "fix(frontend): reload page on Vite chunk preload error after upgrade"

This reverts commit bf0754d21e.

* fix(frontend): resolve lazy chunk URLs against runtime base path

Vite's default chunk-preload helper prepends a hardcoded `/` to asset
filenames, so dynamic chunk preloads always 404 when the panel is
served under a non-root webBasePath (e.g. /CxuVUNgm5mRLmjPhp3/). Use
experimental.renderBuiltUrl to embed window.X_UI_BASE_PATH (injected
by dist.go) as the runtime prefix, so __vite__mapDeps emits URLs like
`<basePath>assets/<file>` regardless of where the dist is mounted.
2026-05-23 20:55:53 +02:00
MHSanaei
2ed85aadda v3.1.0 2026-05-23 19:53:15 +02:00
Sanaei
b71ed1e3ee feat(bash): prompt for PostgreSQL (#4472)
* feat(install): prompt for SQLite vs PostgreSQL during install

* fix(install): write env file to per-distro path and handle pg-install failure

The env file was hardcoded to /etc/default/x-ui, but RHEL/Fedora units read
/etc/sysconfig/x-ui, Arch reads /etc/conf.d/x-ui, and Alpine OpenRC auto-
sources /etc/conf.d/x-ui. PostgreSQL selection was silently dropped on every
distro except Debian. Also initdb on openSUSE (service wouldn't start) and
prompt the operator on local-install failure instead of silently demoting
to SQLite.

* fix(scripts): make x-ui.sh and update.sh PostgreSQL-aware

update.sh ran setting -show and migrate without sourcing the env file, so
PostgreSQL users had migrations applied to the SQLite default and settings
introspection read the wrong DB. Sourcing the per-distro env file at the
start of update_x-ui exports XUI_DB_TYPE/XUI_DB_DSN to all binary calls.

x-ui.sh now shows the active backend in View Current Settings (password
masked) and removes the env file on uninstall so a later reinstall doesn't
inherit a stale DSN.
2026-05-23 19:52:37 +02:00
Sanaei
95aebf1d83 i18n: translate hardcoded inbound action + security warning strings (#4502)
The inbound row actions (delete / reset traffic / clone / export links /
export subscription links / show JSON / export-all variants) and the
security warning alert on the Settings page were emitting English text
directly. Replace them with i18n keys and add translations across all
13 supported locales.
2026-05-23 19:43:21 +02:00
Sanaei
09df07ddf5 perf(frontend): lazy-load modals + split heavy vendor chunks (#4501)
* perf(frontend): lazy-load modals on inbounds / clients / index pages

Modals on the three list pages were imported statically, so the JS +
CSS for every form, info, qr, log, backup, metrics, system-history,
version, and config-text modal sat in the initial bundle even though
they're only needed after a click.

Converted those imports to React.lazy() and gated each modal with a
new LazyMount helper that mounts on first open and keeps the component
mounted thereafter so AntD close animations still play.

Build now emits a dedicated chunk per modal — InboundFormModal at
66 kB (13 kB gzipped) and InboundInfoModal at 23 kB (4 kB gzipped)
are the largest, totalling roughly 150 kB of code that no longer
parses on first paint. Profiler measured the inbounds-page React
render tree drop from ~444 ms to ~254 ms on a prod build.

* perf(frontend): split codemirror / jalali / otpauth into lazy vendor chunks

Heavy libs (codemirror, persian-calendar-suite, otpauth) and antd's
rc-/cssinjs transitive deps used to fall into the catch-all `vendor`
chunk and load with every entry point. Give them their own manualChunks
groups so they only load with the lazy modal/page that needs them.

Initial vendor (catch-all) drops from 1293 kB / 408 kB gzip to
76 kB / 27 kB gzip; codemirror (408 kB / 131 kB gzip) is now on the
JsonEditor lazy path instead of the inbounds/clients/index initial load.
2026-05-23 18:56:11 +02:00
Sanaei
c5b71041d3 Reduce list-page payloads with slim/paged endpoints (#4500)
* perf(inbounds): slim list payload + lazy hydrate for row actions

Adds GET /panel/api/inbounds/list/slim that returns the same list shape
but strips every per-client field besides email/enable/comment from
settings.clients[] and skips UUID/SubId enrichment on ClientStats.
The inbounds page only reads those three to compute its client counters
and badges, so the slim variant trims tens of bytes per client (uuid,
password, flow, security, totalGB, expiryTime, limitIp, tgId, ...).
On a panel with thousands of clients this is the dominant load-time
cost.

Detail flows (edit / info / qr / export / clone) call /get/:id through
a new hydrateInbound helper before opening — the slim list view never
needs the secrets it doesn't render.

* perf(clients): server-side pagination + slim row payload

Adds GET /panel/api/clients/list/paged that filters, sorts, and paginates
on the server, returns a slim row shape (drops uuid/password/auth/flow/
security/reverse/tgId per client), and includes a stable summary
(total, active, online[], depleted[], expiring[], deactive[]) computed
across the full DB row set so the dashboard cards don't change as the
user paginates or filters. Page size capped at 200.

useClients now exposes { clients (current page), total, filtered, query,
setQuery, summary, hydrate }. ClientsPage feeds its filter/sort/page
state into setQuery via a single effect, debounces search by 300ms, and
hydrates the full client record via /get/:email before opening edit/info/
qr modals. Local filter/sort logic and the all-clients summary memo are
gone.

On a 2000-client panel this turns the initial response from ~MB to ~25 row
slice (~10s of KB) and removes the all-client parse cost from every
refresh.

* perf(settings): use /inbounds/options for LDAP tag picker

The General settings tab only needs each inbound's tag/protocol/port to
fill a dropdown but was calling /panel/api/inbounds/list which ships the
full settings JSON with every embedded client. Switched it to /options
and added Tag to the projection. On a panel with thousands of clients
this drops the General-tab load payload from megabytes to a tiny
per-inbound row each.

* perf(clients): de-duplicate options + paged list fetches

Two issues caused each clients-page load to fire its requests twice:

1. setQuery in the hook took whatever object the consumer passed and
   stored it as-is. The consumer (ClientsPage) constructs a new object
   literal in an effect, so even when nothing actually changed the ref
   was new — the hook's useEffect saw a new query and re-fetched.
   Wrapped setQuery with a shallow value compare so identical params
   are a no-op.

2. The picker /inbounds/options fetch was bundled into refresh() with a
   length==0 guard, but the two back-to-back refreshes both saw an
   empty inbounds array (the first hadn't resolved yet) so both fired
   the request. Moved the options fetch into its own one-shot effect.

* perf(inbounds): share nodes list with form modal instead of refetching

InboundsPage and InboundFormModal both called useNodes() — each
instance maintains its own state and fires its own /panel/api/nodes/list
fetch on mount. Since the modal is always rendered (open or not), every
page load hit the endpoint twice.

Threaded nodes from the page through an availableNodes prop on the form
modal so they share one fetch.

* docs(api): register /clients/list/paged endpoint

TestAPIRoutesDocumented was failing because the new paginated clients
endpoint added in this branch wasn't listed in endpoints.js.
2026-05-23 17:43:43 +02:00
Sanaei
9c60ed7ea8 Bulk extend client expiry / traffic + clients page polish (#4499)
* chore(sub): drop unused getFallbackMaster

projectThroughFallbackMaster fully supersedes it for both
panel-tracked and legacy unix-socket fallbacks.

* feat(clients): bulk extend expiry / traffic for selected clients

Adds POST /panel/api/clients/bulkAdjust which shifts ExpiryTime by
addDays and TotalGB by addBytes for every email in one request. The
endpoint is wired into the clients page through a new ClientBulkAdjustModal
that opens from the existing multi-select toolbar.

Clients with unlimited expiry (expiryTime=0) or unlimited traffic
(totalGB=0) are skipped for the corresponding field so bulk extend
never accidentally converts an unlimited client to a limited one.
Negative values are allowed for refunds / corrections.

Translations added for all 13 locales.

* fix(db): silence GORM record-not-found spam in debug mode

getSetting handles ErrRecordNotFound via database.IsNotFound and falls
back to defaults, but GORM's Default logger still logs each miss as an
error. With periodic jobs reading unset keys (xrayTemplateConfig,
externalTrafficInformEnable) the panel log flooded thousands of times.
Switch to a logger.New with IgnoreRecordNotFoundError=true so legitimate
slow-query and SQL traces still surface in debug mode.

* fix(clients): include inboundsById in columns memo deps

Without it, the table's first paint captured an empty inboundsById and
rendered each attached inbound as #<id>. Once a sort/filter forced the
memo to rebuild it self-corrected, hence the visible flicker on reload.

* fix(clients): handle delayed-start expiry in bulk adjust

Negative ExpiryTime encodes a delay duration (magnitude = ms until
the trial begins on first use). Adding positive addDays was simply
arithmetically added, so e.g. a -7d delay + 30d turned into +23d
since epoch (1970), making the client instantly expired.

Branch on sign now: positive ExpiryTime extends additively, negative
extends by subtracting so the value stays negative (more delay).
Cross-sign reductions are skipped with an explicit reason instead of
silently corrupting the field.

* fix(clients): step traffic input by 1 GB instead of 0.1

The +/- buttons on the Total Sent/Received field nudged in 0.1 GB
increments which is too granular for typical use. Set step=1 so each
press moves a whole GB; users can still type decimal values directly.

* fix(inbounds): step Total Flow input by 1 GB instead of 0.1

Matches the same nudge fix applied to the client form's Total
Sent/Received field.
2026-05-23 16:27:20 +02:00
Sanaei
edf0f36940 Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue

Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.

* vite.config.js: react() plugin runs next to vue(); new manualChunks
  for vendor-react / vendor-antd-react / vendor-icons-react /
  vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
  rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
  allowJs: true (lets .tsx files import the remaining .js modules
  during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
  SubPageData shape consumed by the subscription page.

Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.

eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.

* refactor(frontend): port subpage to react+ts

Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.

Introduces two shared pieces both entries (and future ones) will
use:

* src/hooks/useTheme.tsx — React Context + useTheme hook + the
  same buildAntdThemeConfig (dark/ultra-dark token overrides) and
  pauseAnimationsUntilLeave helper the vue version exposes. Same
  localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
  side effects (body.className, html[data-theme]) so the two stay
  in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
  the same web/translation/*.json files via import.meta.glob. The
  vue-i18n setup in src/i18n/index.js is untouched and still serves
  the remaining vue entries.

SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.

* refactor(frontend): port login to react+ts

Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.

* LoginPage.tsx: same form fields, conditional 2FA input,
  rotating headline ("Hello" / "Welcome to..."), drifting blob
  background, theme cycle + language popover. Headline transition
  switches from vue's <Transition mode=out-in> to a CSS keyframe
  animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
  from the vue entry — both are framework-agnostic in src/utils
  and src/api/axios-init.js.

useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.

* refactor(frontend): port api-docs to react+ts

Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.

Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.

Notable transformations:

* The scroll observer that highlights the active TOC link is a
  useEffect keyed on sections — re-registers whenever the visible
  set changes (search filter narrows it). Same behaviour as the vue
  watchEffect.
* v-html="safeInlineHtml(...)" becomes
  dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
  helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
  the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
  signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
  step 1. Drawer + Sider keyed off the same localStorage flag
  (isSidebarCollapsed) and DOM theme attributes the vue version
  uses, so the two stay in sync during coexistence.

* refactor(frontend): port nodes to react+ts

Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.

New shared pieces (live alongside their .vue counterparts during
coexistence):

* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
  and unsubscribes on unmount. The underlying client is a single
  module-level instance so multiple components on the same page
  share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
  the totals memo (online/offline/avgLatency) used by the summary card.
  applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
  suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
  axis scaling, gradient fill, tooltip overlay, and per-instance
  gradient id from React.useId. ResizeObserver lifecycle is in
  useEffect; the math is unchanged.

Pages:

* NodesPage — wires hooks + WebSocket together, renders summary card
  + NodeList, hosts the form modal. Uses Modal.useModal() for the
  delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
  mobile flips to a vertical card list whose actions live in a
  bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
  per change). The reset-on-open effect computes the next state
  once and applies it with eslint-disable to satisfy the new
  react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
  {bucket} every 15s, renders cpu+mem sparklines side-by-side.

* refactor(frontend): port settings to react+ts

Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.

* models/setting.ts — typed AllSetting class with the same field
  defaults and equals() behavior the vue version had. The .js
  twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
  exposes updateSetting(patch), saveDisabled is derived via useMemo
  off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
  of named slots. The vue twin stays alive because xray (BasicsTab,
  DnsTab) still imports it; deleted when xray migrates.

The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.

SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.

SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.

* refactor(frontend): port clients to react+ts

Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).

New shared infra (lives alongside vue twins until inbounds migrates):

* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
  attach/detach + traffic reset, with WebSocket event handlers
  (traffic, client_stats, invalidate) and a small debounced refresh
  on the invalidate event. State managed via setState; the live
  client_stats event merges traffic snapshots row-by-row through a
  ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
  with subscribe/notify so multiple components can read the panel's
  Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
  vue3-persian-datetime-picker has no React port; the Jalali UI
  calendar is deferred (read-only Jalali display via IntlUtil
  formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
  shared between clients (qr modal) and inbounds (still on vue).
  Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
  the clients form needs. The full inbound model stays as
  inbound.js for now; inbounds will pull it in as inbound.ts.

The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.

The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.

ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.

* refactor(frontend): port index dashboard to react+ts

Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.

* refactor(frontend): port xray to react+ts

Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.

The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.

Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.

* Upgrade frontend deps (antd v6, i18n, TS)

Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.

* refactor(frontend): port inbounds to react+ts and drop vue toolchain

Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.

Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.

Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.

* chore(frontend): adopt antd v6 api updates

Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop

Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.

* chore(frontend): antd v6 polish, theme + modal fixes

- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
  inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
  imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
  html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
  actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
  parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
  ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
  to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments

* fix(frontend): qr code collapse — open only first panel, allow toggle

ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.

Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).

* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash

- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
  hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
  the form mounts with saved values instead of relying on a useEffect
  sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
  pointerdown closure so addEventListener/removeEventListener match;
  drag state lives on a ref (from/to/moved) so onUp reads the real
  indices, not stale closure values. Adds setPointerCapture so Windows
  and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
  switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
  treated undefined as "not empty" which crashed VLESSSettings.fromJson
  when json.vnext was missing

* fix(frontend): clipboard reliability + restyle login page

- ClipboardManager.copyText: prefer navigator.clipboard on secure
  contexts, fall back to a focused on-screen textarea + execCommand.
  Old path used left:-9999px which failed selection in some browsers
  and swallowed execCommand's return value, so the "copied" toast
  appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
  glassmorphic card (backdrop-filter blur + saturate), gradient brand
  text/accent, masked grid texture for depth, and a thin gradient
  border on the card. Light/dark/ultra each get their own palette.

* Memoize compactAdvancedJson and update deps

Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.

* style(frontend): prettier charts, drop redundant frame, format net rates

- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
  dashed grid, glowing pulse on the latest-point marker, pill-shaped
  tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
  monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
  inner wrapper I'd added stacked a second border on top); strip the
  decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
  formatter

* style(frontend): refined dark/ultra palette + shared pro card frame

- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
  sidebar/header #15161a (recessed nav, darker than cards), card
  #23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
  the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
  all seven content pages (index, clients, inbounds, xray, settings,
  nodes, api-docs) share one definition instead of duplicating in each
  page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
  larger 17px stat values, subtle gradient divider between stat columns,
  ellipsis on action labels so "Backup & Restore" doesn't break the
  card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards

* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep

- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
  values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
  fails on a fresh clone with an empty dist directory

* docs: refresh frontend docs for the React + TS + AntD 6 stack

Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:

- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout

* style(frontend): improve readability and mobile polish

- bump statistic title/value contrast in dark and ultra-dark so totals
  on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
  Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
  the Xray version tag pattern, and turn it orange when an update is
  available
- make the login settings button a proper circle by adding size="large"
  + an explicit border-radius fallback on .toolbar-btn

* feat: jalali calendar support and date formatting fixes

- Wire useDatepicker into IntlUtil and switch jalalian display locale
  to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
  "AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
  light/dark/ultra theme map and CSS overrides so the inline-styled
  input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
  tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
  render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
  previously only the inbound settings JSON was bumped, so the panel
  never saw a fresh updated_at after editing a client

* feat(frontend): donate link, panel version label, login lang menu

- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales

* fix(xray-update): respect XUI_BIN_FOLDER on Windows

The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.

* Bump Xray to v26.5.9 and minor cleanup

Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.

* fix(frontend): route remaining copy buttons through ClipboardManager

Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.

* fix(db): store CreatedAt/UpdatedAt in milliseconds

GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.

* Improve legacy clipboard copy handling

Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.

* fix(lint): drop redundant ok=false in clipboard fallback catch

* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
2026-05-23 15:21:45 +02:00
MHSanaei
237b7c898d Bump frontend deps: vue and vite
Update frontend dependencies to pull in recent patch fixes and compatibility updates. package.json bumps vue from ^3.5.13 to ^3.5.34 and vite from ^8.0.11 to 8.0.13. package-lock.json updated accordingly (including postcss 8.5.14 → 8.5.15 and nanoid ^3.3.11 → ^3.3.12).
2026-05-21 20:39:07 +02:00
MHSanaei
7368359924 fix(xray): resolve relative log paths under panel log folder
Rewrite relative `log.access`/`log.error` values in the Xray config to
absolute paths under config.GetLogFolder() so Xray writes log files
alongside the panel's logs regardless of the panel's working directory.
Absolute paths, empty/"none" values, and nested relative paths are left
untouched.
2026-05-21 19:15:24 +02:00
MHSanaei
f2f5d584b3 fix(frontend): stack form fields on mobile in client/inbound/node modals
Replace fixed :span values with responsive :xs="24" :md="N" so form rows
collapse to a single column on narrow viewports instead of squeezing.
2026-05-21 18:54:42 +02:00
MHSanaei
3d1d75d65a Revert "build(deps-dev): bump vite from 8.0.13 to 8.0.14 in /frontend (#4487)"
this version of vite have issue
2026-05-21 16:35:33 +02:00
Cheng Ho Ming, Eric
6e2816d035 fix(frontend): override browser default background color on autofilled login inputs (#4478) 2026-05-21 16:24:54 +02:00
dependabot[bot]
7fc7c14ac1 build(deps-dev): bump vite from 8.0.13 to 8.0.14 in /frontend (#4487)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.13 to 8.0.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 15:59:40 +02:00
githacs2022
5f318f3b16 Add SockOpt.Mark and SockOpt.Interface parameters for Outbound stream (#4480) 2026-05-20 22:02:46 +02:00
MHSanaei
9f80cfedab fix(sub): use standard sub://BASE64#REMARK scheme for Shadowrocket 2026-05-19 18:09:51 +02:00
MHSanaei
1b436bb3e0 fix(clients): honor global pageSize and widen size-changer dropdown
Read pageSize from defaultSettings and apply it to the clients table so
the panel-wide pagination preference is respected. Widen the AntD
size-changer trigger and its teleported popup so '100 / page' no longer
truncates.
2026-05-19 17:02:34 +02:00
MHSanaei
5b5ac3f04b fix(migrate): include hysteria, hysteria2, shadowsocks in client sync
The MigrationRequirements protocol filter only covered vmess/vless/trojan,
so orphaned clients in hysteria/hysteria2/shadowsocks inbounds were never
synced into the relational clients table on startup.
2026-05-19 17:02:26 +02:00
MHSanaei
3827d7d061 fix(clients): seed all clients when settings.clients has string tgId
The ClientsTable seeder unmarshaled each settings.clients entry into
model.Client and silently `continue`d on error. Older inbounds wrote
tgId as an empty string for every client past the first; that fails to
unmarshal into int64, so only the first client per inbound landed in
the new clients table.

Normalize tgId and the other int64/int fields on the raw map before
marshal+unmarshal: parseable strings convert, empty/unparseable ones
drop so the field falls back to zero. Also log on the residual
unmarshal-failure path so the next regression is visible.

Recover already-seeded installs by re-syncing each inbound's clients
into the relational tables from MigrationRequirements, so running
`x-ui migrate` heals partial seeds.
2026-05-19 16:10:57 +02:00
MHSanaei
d7f47d8b6a fix(xray): allow private-IP destinations via freedom finalRules
Xray-core v26.4.17 added a default policy that blocks private IPs in the
freedom outbound for vless/vmess/trojan/hysteria/wireguard inbounds,
even when the panel's routing rules send traffic to direct (#4420). The
legacy ipsBlocked override was deprecated in the same release.

Default template now seeds the direct outbound with a finalRules entry
that explicitly allows geoip:private, so users who intentionally remove
the geoip:private->blocked routing rule actually regain LAN access.
Defense in depth is preserved: the routing rule still blocks private
IPs by default, so unmodified configs keep the same behavior.

OutboundFormModal exposes a Final Rules editor under the Freedom
section: per-rule action (allow/block), network, port, IP/CIDR/geoip
tags, and an optional blockDelay for block actions.
2026-05-19 15:42:16 +02:00
Abdalrahman
fd3770c8c9 fix: parse XHTTP extra fields from V2Ray links and v2rayN JSON imports (#4426)
- fromVmessLink: parse all XHTTP bidirectional fields (xPaddingBytes,
  xPaddingObfsMode, session/seq/uplink placements & keys, scMaxEachPostBytes,
  headers) from VMess share link JSON
- fromParamLink: parse same missing fields from the extra JSON param in
  VLESS/Trojan/SS share links and from URL params
- VLESSSettings.fromJson: handle v2rayN-style nested vnext array for
  address/port/id/flow/encryption; previously only flat format was accepted
- StreamSettings.fromJson: accept splithttpSettings as backward-compat
  alias for xhttpSettings, normalize splithttp network to xhttp

Closes #4406

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-19 15:00:15 +02:00
Константин
758e1ad050 Make HSTS policy configurable if https is enabled (#4462)
* Make HSTS policy configurable if https is enabled

* refactor(web): gate HSTS at call site so XUI_SKIP_HSTS doesn't drop the Secure cookie flag

isDirectHTTPSConfigured was being reused for both the HSTS middleware and
the session cookie's Secure flag (web.go:185). Embedding the env-var
check inside it meant setting XUI_SKIP_HSTS=true also stripped Secure
from session cookies on a real HTTPS server. Split the concerns: keep
isDirectHTTPSConfigured honest (cert/key only) and combine it with the
env var at the call site for the HSTS middleware only.

---------

Co-authored-by: Konstantin Kayukin <t_kkayukin@admarketplace.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-19 14:28:05 +02:00
Black
121b6e0bd0 feat(panel): copy connection strings for mixed inbound (#4450)
* feat(panel): copy connection strings for `mixed` inbound

* feat(panel): inline share buttons on desktop, dropdown on mobile

Replace the credentials-copy dropdown with three labeled share buttons
(SOCKS5 / HTTP / Telegram), each with a tooltip preview of the full URL.
Reverse the URI auth position so the format becomes
`scheme://host:port@user:pass` (matches Hiddify-style sharing). Add a
Telegram t.me/socks link with URL-encoded user/pass.

On viewports <=600px the inline row collapses into a single Copy
dropdown to keep the per-account row from wrapping into clutter. RTL
panels are unaffected — the share divider uses inline-* logical props.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-19 14:15:10 +02:00
MHSanaei
bb5ea3af05 revert install.sh 2026-05-19 13:09:35 +02:00
MHSanaei
b36e5e0869 fix(security): redact at source and cap marshal sizes for CodeQL
CodeQL kept flagging the merge logger because taint flowed Password ->
ClientMergeConflict.Old -> log even with a runtime redact helper -- the
analyzer can't prove the branch excludes credentials. Redact at the
source instead: uuid/password/auth/subId now only ever land in the
conflict struct as <redacted> placeholders, so no caller (log or
otherwise) can leak them.

For the ClientWithAttachments marshal overflow alert, replace the
MaxInt-len() arithmetic with explicit per-input size caps (256MB each),
which is the pattern CodeQL's own docs recommend and recognizes.
2026-05-19 12:48:01 +02:00
MHSanaei
788c979ad1 fix(client): guard against int overflow in ClientWithAttachments marshal
CodeQL flagged go/allocation-size-overflow on len(rec)+len(extra) feeding
make's capacity. Not exploitable in practice (both come from json.Marshal
of bounded structs), but add an explicit MaxInt guard to silence the
analyzer and make the precondition obvious.
2026-05-19 12:40:18 +02:00
MHSanaei
66f946ee54 fix(db): redact credentials in client-merge conflict logs
CodeQL flagged go/clear-text-logging: the merge conflict logger printed
raw Old/New/Kept values, which for password/auth/uuid/subId fields meant
credentials landed in plain-text logs. Mask those four fields at the log
site so operators still see which field collided without leaking secrets.
2026-05-19 12:40:11 +02:00