* feat(tgbot): gate the bot behind three user levels Every Telegram account that found the bot could run /help, /status and /usage, and tap any client button it could forge: nothing separated an account no admin had bound from a customer. Each update now resolves to stranger, client or admin, and commands are allowlisted per level so a command added later stays admin-only until it is listed. A stranger may run /start and /id only, and /start answers with the ChatID an admin needs to bind it; a stranger's callbacks are answered and dropped. Client detection reads the same tgId lookup as clientOwnedByTgUser, so the level gate and the ownership check agree. The bot also ignores everything outside private chats: authorization keys on the sender while wizard state keys on the chat, and the two are the same identity only in a private chat. * feat(tgbot): bind Telegram accounts through /start deep links Linking a customer meant the customer sending /id and an admin copying the ChatID into the client by hand, which does not scale past a few customers and is easy to get wrong. The admin client card now offers an invite link, t.me/<bot>?start=<subId>, and the first account to open it is bound through the existing SetClientTelegramUserID. A subId already grants the subscription, so binding gives the holder nothing the token did not. A subscription that spans several clients binds all of them, and is refused if any part belongs to another account; re-opening your own link is idempotent. Unknown and already-claimed tokens share one reply, so the link cannot be used to probe for valid subIds. * fix(tgbot): harden invite claims after review Review of the access-level and binding change found five problems: - Concurrent claims of one link all read the client as unbound, all bound and all were told so, while only the last write held. Resolving and binding now share one lock, and a bind that fails part-way through a multi-client subscription undoes the bindings it already made. - A subId has no minimum strength and the bot needs only its public username, so /start was an unthrottled guessing oracle. Non-admin claim attempts are capped at five per account per hour, the first refused one notifies the admins, and the Subscription ID field now says it doubles as the bot invite code. - levelOf expanded every inbound's client JSON on every non-admin update. It now reads the indexed tg_id column of the clients table. - A button tapped in a group chat was dropped unanswered and kept spinning, with nothing logged. It is answered now, and each ignored chat is logged once. - The subId was pasted raw into the t.me link, so '#' or '&' truncated it and Telegram rejects anything outside A-Za-z0-9_-. The payload is now base64url, and a subId too long for the 64-character limit is refused. * fix(tgbot): answer group chats again and make the claim race test bite ignoredChat dropped every non-private chat because wizard state was keyed by chat while authorization keyed on the sender. #6604 on main re-keyed that state by (chat, user) so admins can drive the bot from a group, so after the merge the drop only took the whole bot away from those admins, report keyboards sent to a group included. The level gate already keys on the sender, so group chats need no special case. TestConcurrentClaimsBindOnlyOneAccount passed with inviteClaimMu removed: the first claimant took the pool's idle connection and bound before the rest had opened theirs, so no two ever raced. It now holds the inbound write the binds need until every claimant has resolved, and fails without the lock ("6 accounts told they bound"). TestCommandAllowed restated the commandsByLevel map; TestGateCommand drives the same allowlist through gateCommand. TestIgnoredChat goes with the code it pinned. * docs(tgbot): document access levels and invite links The command table still said /help and /status answer anyone. An account no admin has linked now reaches only /start and /id, and a customer is linked through the client card's Invite Link, whose token is the Subscription ID. Updated in en, fa, ru and zh. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
3x-ui frontend
React 19 + Ant Design 6 + TypeScript + Vite 8. Three SPA bundles —
index.html (admin panel SPA, all /panel/* routes), login.html
(login + 2FA), and subpage.html (public subscription viewer). All
three are built into ../internal/web/dist/ and embedded into the Go binary
via embed.FS.
State is split between local useState, TanStack Query for server
state, and useTheme / useWebSocket contexts. Form validation,
API parsing, and the xray config model all run through a single
shared Zod schema tree (see Schemas).
Dev
npm install
npm run dev
Vite serves on http://localhost:5173/. API calls and /panel/*
routes proxy to the Go panel at http://localhost:2053/, so start
the Go panel first (go run main.go) and then Vite. The proxy
auto-rewrites /panel, /panel/settings, /panel/inbounds,
/panel/xray to the matching Vite-served HTML, so the sidebar's
production-style links work without round-tripping through Go.
Scripts
| Command | What |
|---|---|
npm run dev |
Vite dev server with API + WS proxy to Go |
npm run build |
Regenerates OpenAPI + Zod, then builds into ../internal/web/dist/ |
npm run preview |
Serve the built bundle locally |
npm run typecheck |
tsc --noEmit (strict, no emit) |
npm run lint |
oxlint over src/ + tools/ (.oxlintrc.json) |
npm run lint:deprecated |
Type-aware sweep for JSDoc @deprecated APIs (on demand) |
npm run format |
oxfmt (.oxfmtrc.json) — rewrites src/ + tools/ in place |
npm run format:check |
oxfmt in check mode (no writes) |
npm run test |
Vitest single run (schema fixtures, link parsers, …) |
npm run test:watch |
Vitest watch mode |
npm run storybook |
Storybook dev server on :6006 (component workbench + autodocs) |
npm run build-storybook |
Static Storybook build — CI compile-checks every story |
npm run gen:api |
Build public/openapi.json from pages/api-docs/endpoints.ts |
npm run gen:zod |
Run the Go-side openapigen tool → src/generated/{zod,types}.ts |
CI runs typecheck, lint, format:check, test, build, and
build-storybook on every PR (see ../.github/workflows/ci.yml).
One-off: scan for deprecated APIs
Run this command to sweep the codebase for usages of APIs marked
with the JSDoc @deprecated tag (AntD prop renames, Zod renames,
removed Web APIs, etc.):
npm run lint:deprecated
It is oxlint's type-aware mode (oxlint-tsgolint, which drives the
TypeScript 7 typescript-go checker) narrowed to no-deprecated, and
is not wired into npm run lint because typed linting needs a full
type-check pass.
Production build
npm run build
Outputs to ../internal/web/dist/ (HTML at the root, hashed JS/CSS under
assets/). manualChunks splits AntD, icons, codemirror, and
react-query into separate vendor bundles to keep the per-page
initial JS small. The Go binary embeds this directory at compile
time and internal/web/controller/dist.go serves the per-page HTML.
PWA mode
The login and panel pages expose a minimal network-only Progressive Web App.
The manifest, service worker, registration script, and icons are embedded with
the frontend and served under the runtime webBasePath. The service worker
does not use Cache Storage, does not intercept requests, and does not provide
offline access; panel authentication, API calls, and WebSocket traffic remain
normal network requests.
Layout
frontend/
├── index.html, login.html, subpage.html # 3 Vite entries
├── tsconfig.json
├── .oxlintrc.json # oxlint config (replaces the ESLint flat config)
├── .oxfmtrc.json # oxfmt config (Prettier-compatible settings)
├── tools/oxlint/
│ └── input-number-guard.mjs # oxlint JS plugin: the #6121/#6127 cleared-
│ # InputNumber guard (oxlint has no
│ # no-restricted-syntax)
├── vitest.config.ts
├── vite.config.js
├── .storybook/ # Storybook config (main.ts, preview.tsx)
├── scripts/
│ └── build-openapi.mjs # endpoints.ts → openapi.json
└── src/
├── entries/ # Per-page bootstrap (createRoot + render)
├── main.tsx # Shared root for the admin SPA (index.html)
├── routes.tsx # react-router routes mounted under /panel/
├── pages/ # One folder per route, page component + helpers
│ ├── index/, login/, inbounds/, clients/, xray/, nodes/,
│ ├── settings/, api-docs/, sub/
├── layouts/ # AdminLayout (sidebar + header + outlet)
├── components/ # Cross-page React components (+ co-located *.stories.tsx)
├── hooks/ # useClients, useTheme, useWebSocket, …
├── api/ # fetch client + CSRF handling, TanStack Query bridge,
│ # WebSocket client + queryClient.ts
├── i18n/ # react-i18next init (locales in internal/web/translation/)
├── lib/xray/ # Pure functions: link generation, defaults,
│ # form ⇄ wire adapters, protocol capabilities
├── schemas/ # Zod source-of-truth (see "Schemas" below)
├── generated/ # Code-generated zod + ts types from Go
│ # (DO NOT hand-edit — regenerated by gen:zod)
├── models/ # Thin legacy types still in transit
│ # (DBInbound, Status, AllSetting)
├── styles/ # Shared CSS modules
├── test/ # Vitest specs + golden fixtures
│ ├── *.test.ts
│ ├── __snapshots__/
│ └── golden/fixtures/ # Per-(protocol × network × security) JSON
└── utils/ # HttpUtil, ClipboardManager, SizeFormatter, …
Schemas
src/schemas/ is the single source of truth for the xray
configuration model. Every API response is parsed through it,
every form field is validated against it, and TypeScript types
are inferred via z.infer<typeof X> — never hand-written.
schemas/
├── primitives/ # Atomic reusable schemas (port, protocol, sniffing, …)
├── api/ # Backend response shapes (e.g. SlimInboundSchema)
├── forms/ # User-facing form shapes (narrower than api/)
├── protocols/
│ ├── inbound/ # Per-protocol settings (vmess, vless, trojan, …)
│ ├── outbound/
│ ├── stream/ # Network transports (tcp, ws, grpc, xhttp, kcp, …)
│ └── security/ # TLS, Reality, none
├── client.ts, dns.ts, routing.ts, setting.ts, status.ts, xray.ts
└── _envelope.ts # Generic `Msg<T>` envelope wrapper
Patterns:
- Discriminated unions for polymorphic data — inbound
settingsisz.discriminatedUnion('protocol', […]), same for stream and security. - Three validation layers, non-overlapping:
- API boundary:
parseMsg(msg, schema, ctx)inside TanStack QueryqueryFn— warn-only in prod, throws in dev - Form input:
antdRule(schema.shape.field)on every<Form.Item>— blocks submit + per-field inline error - Wire request:
Schema.parse(payload)insidemutationFn— throws, because a malformed payload here is always a developer bug
- API boundary:
- No
.loose()or[key: string]: anyin production schemas.typescript/no-explicit-any: erroris enforced by oxlint.
Form pattern (Pattern A)
All non-trivial modals use this single pattern:
const [form] = Form.useForm<InboundFormValues>();
const onFinish = async () => {
const values = await form.validateFields();
await createInbound.mutateAsync(values);
};
<Form form={form} onFinish={onFinish}>
<Form.Item
name="port"
label="Port"
rules={[antdRule(InboundFormSchema.shape.port, t)]}
>
<InputNumber min={1} max={65535} />
</Form.Item>
</Form>
No safeParse-on-submit handlers, no useRef<any> for form
references, no inline z.string().min(1) in rules. Conditional
fields use <Form.Item dependencies={...} shouldUpdate> with the
nested protocol schema.
Testing
Vitest runs everything under src/test/. Schemas have golden
fixture suites — one JSON per (protocol × network × security)
combination round-tripped through schema.parse → link generator
→ snapshot. Regenerate snapshots after intentional changes:
npx vitest run -u
Fixtures live in src/test/golden/fixtures/ and are auto-discovered
via import.meta.glob.
Storybook
Reusable components in src/components/ are developed and documented in
Storybook (@storybook/react-vite). It is a component workbench, not part
of the shipped panel — nothing here is embedded into the Go binary. The built
Storybook is published with the docs site at
docs.sanaei.dev/storybook by
.github/workflows/docs-deploy.yml.
npm run storybook # dev server on http://localhost:6006
npm run build-storybook # static build; CI runs this to compile-check every story
Addons: @storybook/addon-docs renders an autodocs page per component,
@storybook/addon-a11y flags accessibility issues in the canvas, and
@storybook/addon-vitest runs every story as a headless-browser test under
npm run test (Playwright/Chromium — run npx playwright install chromium once
locally). The .storybook/preview.tsx decorator wraps every story in the AntD
ConfigProvider and adds a light/dark theme toggle to the toolbar.
Conventions for a story:
- Co-locate it with its component as
<Component>.stories.tsx. - Set
tags: ['autodocs']so it gets a generated docs page. - Document props via story metadata, not JSDoc (the repo bans
//comments): a component summary inparameters.docs.description.componentand per-prop text inargTypes[prop].description.satisfies Meta<typeof Component>keeps the metadata type-checked.
Adding a new page
Most new routes go inside the admin SPA (index.html) via
routes.tsx — no new HTML or Vite entry needed.
- Add the page component under
src/pages/<page>/. - Register it in
src/routes.tsxunder the/panel/...tree. - If you need a brand-new top-level bundle (login-style standalone
page), add the HTML at
frontend/<page>.html, an entry atsrc/entries/<page>.tsx, and register it inrollupOptions.inputinvite.config.js. Then add the Go controller call toserveDistPage(c, "<page>.html").