Sfoglia il codice sorgente

refactor: focused service files, leaf subpackages, and an internal/ layout (#5167)

* refactor(service): split client.go into focused files

client.go had grown to 4455 lines mixing ~10 responsibilities. Split it
verbatim into cohesive same-package files (no behavior change):

  client.go            foundation: ClientService, ClientWithAttachments,
                       ClientCreatePayload, ErrClientNotInInbound, sqlInChunk
  client_locks.go      inbound mutation locks, delete tombstones, compactOrphans
  client_lookup.go     read-only lookups (GetByID, List, EffectiveFlow, ...)
  client_link.go       inbound association sync (SyncInbound, DetachInbound, ...)
  client_crud.go       single-client CRUD + validation + protocol defaults
  client_inbound_apply.go  low-level inbound-settings mutators + by-email setters
  client_bulk.go       bulk attach/detach/adjust/delete/create + DelDepleted
  client_traffic.go    traffic-reset paths
  client_groups.go     client group management
  client_paging.go     paged listing, filtering, sorting, summary

Every declaration moved unchanged (verified: identical func/type/const/var
signature set before vs after). Imports redistributed per file via goimports.
go build ./..., go vet, and go test ./web/service/... all pass.

* refactor(service): split inbound.go into focused files

inbound.go was 4100 lines. Split it verbatim into cohesive same-package
files (no behavior change):

  inbound.go             core inbound CRUD + InboundService (keeps pkg doc)
  inbound_protocol.go    protocol / stream capability helpers
  inbound_node.go        node/runtime/remote coordination + online tracking
  inbound_traffic.go     traffic accounting, reset, client stats
  inbound_client_ips.go  per-client IP tracking
  inbound_clients.go     client lookups within inbounds + copy-clients
  inbound_disable.go     auto-disable invalid inbounds/clients
  inbound_migration.go   DB migrations
  inbound_sublink.go     subscription link providers
  inbound_util.go        generic slice/string helpers

Identical func/type/const/var signature set before vs after; package doc
comment preserved on inbound.go. Imports redistributed via goimports.
Build, vet, and go test ./web/service/... all pass.

* refactor(service): split tgbot.go into focused files

tgbot.go was 3738 lines dominated by a 1246-line answerCallback. Split it
verbatim into cohesive same-package files (no behavior change):

  tgbot.go           lifecycle, bot setup, caches, small utils
  tgbot_router.go    incoming update / command / callback dispatch
  tgbot_send.go      outbound messaging primitives
  tgbot_client.go    client views, actions, subscription links
  tgbot_inbound.go   inbound listing / pickers
  tgbot_report.go    server usage, exhausted, online, backups, notifications

Identical func/type/const/var signature set before vs after. Imports
redistributed via goimports. Build, vet, and go test ./web/service/... pass.

* refactor(client): dedupe single-field by-email setters

ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, and
ResetClientTrafficLimitByEmail shared an identical ~50-line body that
resolves the inbound by email, confirms the client exists, rewrites a
single-client settings payload, and delegates to UpdateInboundClient.

Extract that into applyClientFieldByEmail(inboundSvc, email, mutate) and
reduce each setter to a 3-line wrapper. Behavior is unchanged: same checks
and error strings, same single-client payload contract, same totalGB guard.

SetClientTelegramUserID (resolves by traffic id, different error text) and
ToggleClientEnableByEmail/SetClientEnableByEmail (different return shape and
a pre-read of the old state) intentionally keep their own bodies.

* refactor(service): extract panel/ subpackage

Move the panel-administration leaf services out of the flat service
package into web/service/panel/ (package panel):

  user.go         UserService (auth / 2FA / LDAP)
  panel.go        PanelService (restart / self-update) + version helpers
  panel_other.go  non-unix RestartPanel
  panel_unix.go   unix RestartPanel
  api_token.go    ApiTokenService
  websocket.go    WebSocketService
  panel_test.go   version/shellQuote unit tests

These are leaves: they depend on core (SettingService, Release) but no
core file references them, so the extraction creates no import cycle.
Core references are now qualified (service.SettingService, service.Release);
callers in main.go, web/web.go, and web/controller/* updated to panel.*.
Build, vet, and go test ./web/... pass.

* refactor(service): extract integration/ subpackage

Move the external-provider integration leaves into web/service/integration/
(package integration):

  warp.go        WarpService (Cloudflare WARP)
  nord.go        NordService (NordVPN)
  custom_geo.go  CustomGeoService (custom geo asset management)
  *_test.go      custom_geo / panel-proxy tests

These depend on core (SettingService, ServerService, XraySettingService) but
no core file references them. xray_setting.go stays in core because it calls
the unexported SettingService.saveSetting. The shared isBlockedIP SSRF helper
(used by core url_safety.go and by custom_geo) now has a small copy in each
package rather than being exported. Core references qualified; callers in
web/web.go, web/job/*, and web/controller/* updated to integration.*.
Build, vet, and go test ./web/... pass.

* refactor(service): extract tgbot/ subpackage

Move the Telegram bot (6 files + test) into web/service/tgbot/ (package
tgbot). It is a leaf: it embeds five core services (Inbound/Client/Setting/
Server/Xray) and the core never references it, so no import cycle.

To support the package boundary without changing behavior:
  - core exposes XrayProcess() *xray.Process so tgbot keeps calling the
    exact same running-process methods it used via the package-level `p`;
  - three core methods tgbot calls are exported: ClientService.checkIs-
    EnabledByEmail -> CheckIsEnabledByEmail, InboundService.getAllEmails ->
    GetAllEmails (callers updated in-package);
  - tgbot's embedded-field types and the few core type refs (Status,
    ClientCreatePayload, SanitizePublicHTTPURL) are now service-qualified.

Callers in main.go, web/web.go, web/job/*, and web/controller/* updated to
tgbot.*. Build, vet, and go test ./web/... pass.

* refactor(service): extract outbound/ subpackage

OutboundService (outbound.go) imports only neutral packages (config,
database, model, xray) and its production code is referenced by no core or
sibling service file — only by web/controller/xray_setting.go and
web/job/xray_traffic_job.go. Move it to web/service/outbound/ (package
outbound); no core qualification needed inside. Callers updated to outbound.*.

The one coupling was a tiny pure test helper, outboundsContainTag, used by
both outbound.go and the core outbound_subscription_test.go; it now has a
small copy in that test file rather than being shared across the boundary.
Build, vet, and go test ./web/... pass.

* refactor(util): move wireguard into its own subpackage

util/wireguard.go was the lone file of the root `util` package (24 lines,
one exported func GenerateWireguardKeypair), while every other util concern
lives in a focused subpackage (util/common, util/crypto, util/netsafe, ...).
Move it to util/wireguard/ (package wireguard) for consistency; its only
importer, web/service/integration/warp.go, is updated. The root `util`
package no longer exists.

* refactor(sub): drop redundant sub prefix from filenames

Inside package sub the subXxx.go prefix just repeats the package name
(like client_*.go did inside service). Rename for consistency; content and
type names are unchanged:

  subController.go    -> controller.go
  subService.go       -> service.go
  subClashService.go  -> clash_service.go
  subJsonService.go   -> json_service.go
  (+ matching _test.go files)

* refactor(controller): rename xui.go -> spa.go

XUIController serves the panel's single-page-app shell; spa.go names that
role plainly (the other controller files are domain-named). File rename only
— the type stays XUIController. api_docs_test.go keys route base paths by
filename, so its "xui.go" case is updated to "spa.go".

* refactor: move backend packages under internal/

Adopt the idiomatic Go application layout: the backend packages now live
under internal/ (a boundary the toolchain enforces), signalling private
implementation instead of a library-style flat root. No runtime behavior
changes — only import paths and a few build/config paths move.

Moved: config, database, logger, mtproto, sub, util, web, xray -> internal/.
main.go stays at the repo root and tools/openapigen stays under tools/ (both
still import internal/* because the internal rule keys off the module root).
The module path github.com/mhsanaei/3x-ui/v3 is unchanged; 149 .go files had
their import prefix rewritten to .../internal/<pkg>.

Couplings the Go compiler can't see, updated to the new layout:
  - frontend i18n imports of web/translation (react.ts, setup.components.ts)
  - vite outDir + eslint/tsconfig ignore globs -> internal/web/dist
  - Dockerfile COPY paths for web/dist and web/translation
  - locale.go os.DirFS("web") disk fallback -> "internal/web"
  - .gitignore and ci.yml go:embed stub for internal/web/dist
  - api_docs_test.go repo-root relative walk (one level deeper)
  - tools/openapigen filesystem package paths; ApiTokenView repointed to the
    web/service/panel subpackage and codegen regenerated (clears a stale
    type the ci.yml codegen check was failing on)

Verified: go build/vet/test (all packages), and frontend typecheck, lint,
vitest (478 tests), and production build into internal/web/dist.

* fix(config): keep test runs from writing logs into the source tree

GetLogFolder() returns a CWD-relative "./log" on Windows. Under `go test`
the working directory is each package's own folder, so InitLogger (called by
tests in web/job, web/service, xray, web/websocket) created stray log/
directories scattered through the source tree (e.g. internal/web/job/log/).

Redirect to a shared temp folder when testing.Testing() reports a test run.
Production behavior is unchanged: Windows still uses ./log next to the binary
and Linux /var/log/x-ui. The log files were always gitignored (*.log) and
never committed; this just stops the noise at the source.

* docs: move subscription-template guide out of root into docs/

sub_templates/ was a top-level folder holding only a README and no actual
templates (3x-ui ships none by design), referenced nowhere and unlinked from
any doc — it read like an empty placeholder cluttering the repo root.

Move the guide to docs/custom-subscription-templates.md (a proper docs home),
reword its intro to read as documentation rather than a folder note, link it
from the Features list in README.md, and drop the empty sub_templates/ folder.

* fix: update stale web/ path references after the internal/ move

The internal/ migration rewrote Go import paths but left some references to
the old top-level layout in docs, comments, and a few runtime disk paths.

Functional (dev-mode only): the disk-serving fallbacks that read the Vite
build from disk when running from source still pointed at web/dist/, which
moved to internal/web/dist/ — so `os.DirFS`/`os.Stat`/`os.ReadFile` in
internal/web/web.go and internal/sub/{sub,controller}.go are corrected.
Production was unaffected (it serves the embedded FS; verified by the Docker
build), but `go run` with a live frontend build silently fell back to embed.

Docs/comments: frontend/README.md, CONTRIBUTING.md, the claude-issue-bot and
release workflows, the openapigen -root help text, and assorted Go comments
now reference internal/web, internal/database, internal/sub, internal/xray,
etc. Package-name mentions (the "web" package), root paths (main.go,
frontend/, install scripts, /etc/x-ui), routes (/panel/api/xray), and the
historical "web/assets no longer exists" note were intentionally left as-is.

* refactor(web): remove the legacy /xui -> /panel redirect middleware

RedirectMiddleware existed only for backward compatibility with the old
`/xui` URL scheme (301-redirecting /xui and /xui/API to /panel and
/panel/api). That cutover was long ago, so drop the middleware, its
registration in initRouter, and the now-inaccurate "URL redirection"
mention in the middleware package doc. Old /xui URLs now 404 like any other
unknown path. HTTPS auto-redirect and auth redirects are unrelated and stay.

* build: fix .dockerignore for internal/ layout and exclude runtime dir

- web/dist -> internal/web/dist: the embedded frontend moved under internal/,
  so the stale exclude no longer matched and the locally-built dist could be
  sent to the build context (the frontend stage rebuilds it fresh anyway).
- exclude x-ui/: the local runtime directory (SQLite db, geo .dat files, xray
  binaries, certs — ~150MB) was being shipped into the build context for no
  reason. Verified the pattern excludes only the directory and still keeps
  x-ui.sh, which the Dockerfile copies to /usr/bin/x-ui.
Sanaei 8 ore fa
parent
commit
41645255f1
100 ha cambiato i file con 231 aggiunte e 222 eliminazioni
  1. 2 1
      .dockerignore
  2. 4 4
      .github/workflows/ci.yml
  3. 18 19
      .github/workflows/claude-issue-bot.yml
  4. 2 2
      .github/workflows/release.yml
  5. 4 7
      .gitignore
  6. 9 9
      CONTRIBUTING.md
  7. 3 3
      Dockerfile
  8. 1 1
      README.md
  9. 1 1
      docs/custom-subscription-templates.md
  10. 5 5
      frontend/README.md
  11. 1 1
      frontend/eslint.config.js
  12. 1 1
      frontend/eslint.deprecated.config.js
  13. 0 1
      frontend/src/generated/types.ts
  14. 0 3
      frontend/src/generated/zod.ts
  15. 4 4
      frontend/src/i18n/react.ts
  16. 1 1
      frontend/src/test/setup.components.ts
  17. 1 1
      frontend/tsconfig.json
  18. 1 1
      frontend/vite.config.js
  19. 7 0
      internal/config/config.go
  20. 0 0
      internal/config/name
  21. 0 0
      internal/config/version
  22. 5 5
      internal/database/db.go
  23. 1 1
      internal/database/db_seed_test.go
  24. 0 0
      internal/database/dialect.go
  25. 0 0
      internal/database/dump_sqlite.go
  26. 2 2
      internal/database/dump_sqlite_test.go
  27. 3 3
      internal/database/migrate_data.go
  28. 1 1
      internal/database/migrate_data_test.go
  29. 2 2
      internal/database/model/model.go
  30. 0 0
      internal/database/model/model_mtproto_test.go
  31. 0 0
      internal/database/model/model_test.go
  32. 0 0
      internal/database/model/node_client_traffic.go
  33. 1 1
      internal/logger/logger.go
  34. 2 2
      internal/mtproto/manager.go
  35. 1 1
      internal/mtproto/manager_test.go
  36. 0 0
      internal/mtproto/orphans_linux.go
  37. 0 0
      internal/mtproto/orphans_other.go
  38. 2 2
      internal/mtproto/process.go
  39. 0 0
      internal/mtproto/process_other.go
  40. 1 1
      internal/mtproto/process_windows.go
  41. 1 1
      internal/sub/build_urls_test.go
  42. 3 3
      internal/sub/clash_service.go
  43. 1 1
      internal/sub/clash_service_test.go
  44. 4 4
      internal/sub/controller.go
  45. 0 0
      internal/sub/controller_test.go
  46. 0 0
      internal/sub/default.json
  47. 2 2
      internal/sub/dist.go
  48. 5 5
      internal/sub/json_service.go
  49. 1 1
      internal/sub/json_service_test.go
  50. 2 2
      internal/sub/links.go
  51. 0 0
      internal/sub/links_test.go
  52. 9 9
      internal/sub/service.go
  53. 1 1
      internal/sub/service_test.go
  54. 3 3
      internal/sub/service_userinfo_test.go
  55. 8 8
      internal/sub/sub.go
  56. 1 1
      internal/util/common/err.go
  57. 0 0
      internal/util/common/format.go
  58. 0 0
      internal/util/common/format_test.go
  59. 0 0
      internal/util/common/multi_error.go
  60. 0 0
      internal/util/common/multi_error_test.go
  61. 0 0
      internal/util/crypto/crypto.go
  62. 0 0
      internal/util/crypto/crypto_test.go
  63. 0 0
      internal/util/json_util/json.go
  64. 0 0
      internal/util/json_util/json_test.go
  65. 0 0
      internal/util/ldap/ldap.go
  66. 0 0
      internal/util/link/outbound.go
  67. 0 0
      internal/util/link/outbound_test.go
  68. 0 0
      internal/util/netproxy/netproxy.go
  69. 0 0
      internal/util/netproxy/netproxy_test.go
  70. 0 0
      internal/util/netsafe/netsafe.go
  71. 0 0
      internal/util/netsafe/netsafe_test.go
  72. 0 0
      internal/util/random/random.go
  73. 0 0
      internal/util/random/random_test.go
  74. 0 0
      internal/util/reflect_util/reflect.go
  75. 0 0
      internal/util/sys/psutil.go
  76. 0 0
      internal/util/sys/sys_darwin.go
  77. 0 0
      internal/util/sys/sys_linux.go
  78. 0 0
      internal/util/sys/sys_windows.go
  79. 1 1
      internal/util/wireguard/wireguard.go
  80. 11 8
      internal/web/controller/api.go
  81. 2 2
      internal/web/controller/api_docs_test.go
  82. 3 3
      internal/web/controller/base.go
  83. 3 3
      internal/web/controller/client.go
  84. 19 19
      internal/web/controller/custom_geo.go
  85. 3 3
      internal/web/controller/dist.go
  86. 0 0
      internal/web/controller/dist_test.go
  87. 2 2
      internal/web/controller/group.go
  88. 5 5
      internal/web/controller/inbound.go
  89. 14 12
      internal/web/controller/index.go
  90. 0 0
      internal/web/controller/login_limiter.go
  91. 0 0
      internal/web/controller/login_limiter_test.go
  92. 3 3
      internal/web/controller/node.go
  93. 9 8
      internal/web/controller/server.go
  94. 9 8
      internal/web/controller/setting.go
  95. 3 3
      internal/web/controller/spa.go
  96. 3 3
      internal/web/controller/util.go
  97. 0 0
      internal/web/controller/util_test.go
  98. 6 6
      internal/web/controller/websocket.go
  99. 7 5
      internal/web/controller/xray_setting.go
  100. 1 1
      internal/web/entity/entity.go

+ 2 - 1
.dockerignore

@@ -1,9 +1,10 @@
 .git
 **/node_modules
-web/dist
+internal/web/dist
 build
 db
 cert
 pgdata
+x-ui/
 *.db
 *.dump

+ 4 - 4
.github/workflows/ci.yml

@@ -30,8 +30,8 @@ jobs:
         with:
           go-version-file: go.mod
           cache: true
-      - name: Stub web/dist for go:embed
-        run: mkdir -p web/dist && touch web/dist/.gitkeep
+      - name: Stub internal/web/dist for go:embed
+        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
       - name: Test
         run: |
           go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
@@ -62,8 +62,8 @@ jobs:
         with:
           go-version-file: go.mod
           cache: true
-      - name: Stub web/dist for go:embed
-        run: mkdir -p web/dist && touch web/dist/.gitkeep
+      - name: Stub internal/web/dist for go:embed
+        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
       - name: Install govulncheck
         run: go install golang.org/x/vuln/cmd/govulncheck@latest
       - name: Run govulncheck

File diff suppressed because it is too large
+ 18 - 19
.github/workflows/claude-issue-bot.yml


+ 2 - 2
.github/workflows/release.yml

@@ -53,8 +53,8 @@ jobs:
           check-latest: true
 
       # Frontend dist must be built BEFORE go build — Go's //go:embed
-      # all:dist directive in web/web.go requires web/dist/ to exist
-      # at compile time. web/dist/ is .gitignored, so on a fresh CI
+      # all:dist directive in internal/web/web.go requires internal/web/dist/ to exist
+      # at compile time. internal/web/dist/ is .gitignored, so on a fresh CI
       # checkout it doesn't exist until vite emits it.
       - name: Setup Node.js
         uses: actions/setup-node@v6

+ 4 - 7
.gitignore

@@ -16,20 +16,17 @@ tmp/
 # Ignore build and distribution directories
 backup/
 bin/
+x-ui/
 dist/
-!web/dist/
-web/dist/*
-!web/dist/.gitkeep
+!internal/web/dist/
+internal/web/dist/*
+!internal/web/dist/.gitkeep
 release/
 node_modules/
 
 # Ignore compiled binaries
 main
 
-# Ignore script and executable files
-/release.sh
-/x-ui
-
 # Ignore OS specific files
 .DS_Store
 Thumbs.db

+ 9 - 9
CONTRIBUTING.md

@@ -135,7 +135,7 @@ The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`
 
 ### Architecture
 
-The frontend ships **three Vite bundles**, each emitted into `web/dist/` and embedded into the Go binary at compile time via `embed.FS`:
+The frontend ships **three Vite bundles**, each emitted into `internal/web/dist/` and embedded into the Go binary at compile time via `embed.FS`:
 
 - **`index.html`** — the admin panel, a **single-page app**. `src/main.tsx` mounts a `react-router` `createBrowserRouter` (see `src/routes.tsx`) under the `/panel` basename; every route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/groups`, `/panel/nodes`, `/panel/settings`, `/panel/xray`, `/panel/api-docs`) is lazy-loaded inside a shared `PanelLayout` (sidebar + header + `<Outlet>`).
 - **`login.html`** — the login + 2FA screen (`src/entries/login.tsx`), a standalone bundle.
@@ -153,7 +153,7 @@ Panel navigation happens client-side through React Router, and per-route code is
 
 ### i18n
 
-Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
+Locale strings live in `internal/web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
 
 ### Two dev workflows
 
@@ -184,7 +184,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
 - **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
 - **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
 - **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
-- **Document new endpoints.** Every new `g.POST`/`g.GET` in `web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
+- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
 - **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
 - **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — currently `8.0.16` — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
 
@@ -209,7 +209,7 @@ frontend/
     ├── components/        — cross-page React components
     ├── hooks/             — reusable hooks (useTheme, useWebSocket, useClients, useDatepicker, …)
     ├── api/               — Axios + CSRF interceptor, TanStack Query provider/keys, WebSocket client
-    ├── i18n/              — react-i18next bootstrap (JSON lives in web/translation/)
+    ├── i18n/              — react-i18next bootstrap (JSON lives in internal/web/translation/)
     ├── lib/xray/          — pure xray logic: link generation, defaults, form ⇄ wire adapters
     ├── schemas/           — Zod source of truth for the xray config model
     ├── generated/         — code-generated Zod + TS types from Go (do not hand-edit)
@@ -226,12 +226,12 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
 | Path | Contents |
 |------|----------|
 | `main.go` | Process entry point, CLI subcommands, signal handling |
-| `web/` | Gin HTTP server, controllers, services, embedded frontend assets |
+| `internal/web/` | Gin HTTP server, controllers, services, embedded frontend assets |
 | `frontend/` | React + Ant Design 6 + TypeScript source for the panel UI |
-| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
-| `xray/` | Xray-core process lifecycle and gRPC API client |
-| `sub/` | Subscription endpoints (raw, JSON, Clash) |
-| `config/` | Environment-variable helpers, paths, defaults |
+| `internal/database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
+| `internal/xray/` | Xray-core process lifecycle and gRPC API client |
+| `internal/sub/` | Subscription endpoints (raw, JSON, Clash) |
+| `internal/config/` | Environment-variable helpers, paths, defaults |
 | `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
 
 ## Sending a pull request

+ 3 - 3
Dockerfile

@@ -6,7 +6,7 @@ WORKDIR /src/frontend
 COPY frontend/package.json frontend/package-lock.json ./
 RUN npm ci
 COPY frontend/ ./
-COPY web/translation /src/web/translation
+COPY internal/web/translation /src/internal/web/translation
 RUN npm run build
 
 # ========================================================
@@ -23,7 +23,7 @@ RUN apk --no-cache --update add \
   unzip
 
 COPY . .
-COPY --from=frontend /src/web/dist ./web/dist
+COPY --from=frontend /src/internal/web/dist ./internal/web/dist
 
 ENV CGO_ENABLED=1
 ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
@@ -48,7 +48,7 @@ RUN apk add --no-cache --update \
 COPY --from=builder /app/build/ /app/
 COPY --from=builder /app/DockerEntrypoint.sh /app/
 COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
-COPY --from=builder /app/web/translation /app/web/translation
+COPY --from=builder /app/internal/web/translation /app/internal/web/translation
 
 
 # Configure fail2ban

+ 1 - 1
README.md

@@ -33,7 +33,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
 - **Traffic statistics** — per inbound, per client, and per outbound, with reset controls.
 - **Multi-node support** — manage and scale across multiple servers from a single panel.
 - **Outbound & routing** — WARP, NordVPN, custom routing rules, load balancers, and outbound proxy chaining.
-- **Built-in subscription server** with multiple output formats.
+- **Built-in subscription server** with multiple output formats and [custom page templates](docs/custom-subscription-templates.md).
 - **Telegram bot** for remote monitoring and management.
 - **RESTful API** with in-panel Swagger documentation.
 - **Flexible storage** — SQLite (default) or PostgreSQL.

+ 1 - 1
sub_templates/README.md → docs/custom-subscription-templates.md

@@ -1,6 +1,6 @@
 # 3x-ui Custom Subscription Templates
 
-This directory allows you to use custom HTML templates for your users' subscription pages.
+3x-ui can render your users' subscription pages from your own custom HTML templates.
 
 ## How to use a Custom Template
 

+ 5 - 5
frontend/README.md

@@ -3,7 +3,7 @@
 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 `../web/dist/` and embedded into the Go binary
+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
@@ -30,7 +30,7 @@ production-style links work without round-tripping through Go.
 | Command | What |
 |---|---|
 | `npm run dev` | Vite dev server with API + WS proxy to Go |
-| `npm run build` | Regenerates OpenAPI + Zod, then builds into `../web/dist/` |
+| `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` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
@@ -62,11 +62,11 @@ the wall-clock time.
 npm run build
 ```
 
-Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
+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 `web/controller/dist.go` serves the per-page HTML.
+time and `internal/web/controller/dist.go` serves the per-page HTML.
 
 ## Layout
 
@@ -93,7 +93,7 @@ frontend/
     ├── hooks/           # useClients, useTheme, useWebSocket, …
     ├── api/             # Axios + CSRF interceptor, TanStack Query bridge,
     │                    #   WebSocket client + queryClient.ts
-    ├── i18n/            # react-i18next init (locales in web/translation/)
+    ├── 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)

+ 1 - 1
frontend/eslint.config.js

@@ -4,7 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks';
 import globals from 'globals';
 
 export default [
-  { ignores: ['node_modules/**', '../web/dist/**'] },
+  { ignores: ['node_modules/**', '../internal/web/dist/**'] },
   js.configs.recommended,
   ...tseslint.configs.recommended.map((config) => ({
     ...config,

+ 1 - 1
frontend/eslint.deprecated.config.js

@@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint';
 import reactHooks from 'eslint-plugin-react-hooks';
 
 export default [
-  { ignores: ['node_modules/**', '../web/dist/**', 'src/generated/**'] },
+  { ignores: ['node_modules/**', '../internal/web/dist/**', 'src/generated/**'] },
   {
     files: ['**/*.{ts,tsx}'],
     plugins: {

+ 0 - 1
frontend/src/generated/types.ts

@@ -1,5 +1,4 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
-export type LoginStatus = number;
 export type ProcessState = string;
 export type Protocol = string;
 export type SubLinkProvider = unknown;

+ 0 - 3
frontend/src/generated/zod.ts

@@ -1,8 +1,5 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
 import { z } from 'zod';
-export const LoginStatusSchema = z.number().int();
-export type LoginStatus = z.infer<typeof LoginStatusSchema>;
-
 export const ProcessStateSchema = z.string();
 export type ProcessState = z.infer<typeof ProcessStateSchema>;
 

+ 4 - 4
frontend/src/i18n/react.ts

@@ -2,17 +2,17 @@ import i18next from 'i18next';
 import { initReactI18next } from 'react-i18next';
 
 import { LanguageManager } from '@/utils';
-import enUS from '../../../web/translation/en-US.json';
+import enUS from '../../../internal/web/translation/en-US.json';
 
 const FALLBACK = 'en-US';
 
 const lazyModules = import.meta.glob([
-  '../../../web/translation/*.json',
-  '!../../../web/translation/en-US.json',
+  '../../../internal/web/translation/*.json',
+  '!../../../internal/web/translation/en-US.json',
 ]);
 
 function moduleKeyFor(code: string): string {
-  return `../../../web/translation/${code}.json`;
+  return `../../../internal/web/translation/${code}.json`;
 }
 
 let active: string = LanguageManager.getLanguage();

+ 1 - 1
frontend/src/test/setup.components.ts

@@ -3,7 +3,7 @@ import { cleanup } from '@testing-library/react';
 import i18next from 'i18next';
 import { initReactI18next } from 'react-i18next';
 
-import enUS from '../../../web/translation/en-US.json';
+import enUS from '../../../internal/web/translation/en-US.json';
 
 vi.mock('persian-calendar-suite', () => ({
   PersianDateTimePicker: () => null,

+ 1 - 1
frontend/tsconfig.json

@@ -25,5 +25,5 @@
     }
   },
   "include": ["src/**/*.ts", "src/**/*.tsx"],
-  "exclude": ["node_modules", "../web/dist"]
+  "exclude": ["node_modules", "../internal/web/dist"]
 }

+ 1 - 1
frontend/vite.config.js

@@ -4,7 +4,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { DatabaseSync } from 'node:sqlite';
 
-const outDir = path.resolve(__dirname, '../web/dist');
+const outDir = path.resolve(__dirname, '../internal/web/dist');
 const BACKEND_TARGET = 'http://localhost:2053';
 
 function resolveDBPath() {

+ 7 - 0
config/config.go → internal/config/config.go

@@ -10,6 +10,7 @@ import (
 	"path/filepath"
 	"runtime"
 	"strings"
+	"testing"
 )
 
 //go:embed version
@@ -140,6 +141,12 @@ func GetLogFolder() string {
 	if logFolderPath != "" {
 		return logFolderPath
 	}
+	// Under `go test` the Windows default below is CWD-relative ("./log"), which
+	// scatters a log/ directory through the source tree (one per tested package).
+	// Redirect test runs to a shared temp folder so the source tree stays clean.
+	if testing.Testing() {
+		return filepath.Join(os.TempDir(), "3x-ui-test-log")
+	}
 	if runtime.GOOS == "windows" {
 		return filepath.Join(".", "log")
 	}

+ 0 - 0
config/name → internal/config/name


+ 0 - 0
config/version → internal/config/version


+ 5 - 5
database/db.go → internal/database/db.go

@@ -16,11 +16,11 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/config"
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/util/crypto"
-	"github.com/mhsanaei/3x-ui/v3/util/random"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"

+ 1 - 1
database/db_seed_test.go → internal/database/db_seed_test.go

@@ -6,7 +6,7 @@ import (
 	"regexp"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestSeedClientsFromInboundJSON_IsIdempotentAgainstExistingClients(t *testing.T) {

+ 0 - 0
database/dialect.go → internal/database/dialect.go


+ 0 - 0
database/dump_sqlite.go → internal/database/dump_sqlite.go


+ 2 - 2
database/dump_sqlite_test.go → internal/database/dump_sqlite_test.go

@@ -5,8 +5,8 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"

+ 3 - 3
database/migrate_data.go → internal/database/migrate_data.go

@@ -11,8 +11,8 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
@@ -25,7 +25,7 @@ import (
 // related tests.
 //
 // Important: When adding a new top-level model (like OutboundSubscription),
-// you must add it here **in addition to** the list in database/db.go:initModels().
+// you must add it here **in addition to** the list in internal/database/db.go:initModels().
 // This list is used for:
 //   - Creating the destination schema during cross-DB migration
 //   - Truncating tables

+ 1 - 1
database/migrate_data_test.go → internal/database/migrate_data_test.go

@@ -4,7 +4,7 @@ import (
 	"os"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"

+ 2 - 2
database/model/model.go → internal/database/model/model.go

@@ -9,8 +9,8 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/util/json_util"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 // Protocol represents the protocol type for Xray inbounds.

+ 0 - 0
database/model/model_mtproto_test.go → internal/database/model/model_mtproto_test.go


+ 0 - 0
database/model/model_test.go → internal/database/model/model_test.go


+ 0 - 0
database/model/node_client_traffic.go → internal/database/model/node_client_traffic.go


+ 1 - 1
logger/logger.go → internal/logger/logger.go

@@ -9,7 +9,7 @@ import (
 	"runtime"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/op/go-logging"
 
 	"gopkg.in/natefinch/lumberjack.v2"

+ 2 - 2
mtproto/manager.go → internal/mtproto/manager.go

@@ -12,8 +12,8 @@ import (
 	"sync"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 // Instance is the desired runtime configuration of one mtproto inbound.

+ 1 - 1
mtproto/manager_test.go → internal/mtproto/manager_test.go

@@ -4,7 +4,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestParseMetricLine(t *testing.T) {

+ 0 - 0
mtproto/orphans_linux.go → internal/mtproto/orphans_linux.go


+ 0 - 0
mtproto/orphans_other.go → internal/mtproto/orphans_other.go


+ 2 - 2
mtproto/process.go → internal/mtproto/process.go

@@ -16,8 +16,8 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/config"
-	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 // GetBinaryName returns the mtg binary filename for the current OS and arch,

+ 0 - 0
mtproto/process_other.go → internal/mtproto/process_other.go


+ 1 - 1
mtproto/process_windows.go → internal/mtproto/process_windows.go

@@ -7,7 +7,7 @@ import (
 	"sync"
 	"unsafe"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"golang.org/x/sys/windows"
 )
 

+ 1 - 1
sub/build_urls_test.go → internal/sub/build_urls_test.go

@@ -5,7 +5,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 )
 
 func initSubDB(t *testing.T) {

+ 3 - 3
sub/subClashService.go → internal/sub/clash_service.go

@@ -8,9 +8,9 @@ import (
 	"github.com/goccy/go-json"
 	yaml "github.com/goccy/go-yaml"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 type SubClashService struct {

+ 1 - 1
sub/subClashService_test.go → internal/sub/clash_service_test.go

@@ -4,7 +4,7 @@ import (
 	"reflect"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestEnsureUniqueProxyNames(t *testing.T) {

+ 4 - 4
sub/subController.go → internal/sub/controller.go

@@ -16,8 +16,8 @@ import (
 	"time"
 
 	"github.com/gin-gonic/gin"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 // writeSubError translates a service-layer result into an HTTP response.
@@ -182,14 +182,14 @@ func (a *SUBController) subs(c *gin.Context) {
 	}
 }
 
-// serveSubPage renders web/dist/subpage.html for the current subscription
+// serveSubPage renders internal/web/dist/subpage.html for the current subscription
 // request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
 // we inject that here, along with window.X_UI_BASE_PATH so the
 // page's static asset references resolve correctly when the panel runs
 // behind a URL prefix.
 func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
 	var body []byte
-	if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
+	if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
 		body = diskBody
 	} else {
 		readBody, err := distFS.ReadFile("dist/subpage.html")

+ 0 - 0
sub/subController_test.go → internal/sub/controller_test.go


+ 0 - 0
sub/default.json → internal/sub/default.json


+ 2 - 2
sub/dist.go → internal/sub/dist.go

@@ -4,9 +4,9 @@ import "embed"
 
 // distFS holds the Vite-built frontend filesystem, injected from main at
 // startup. The `web` package owns the //go:embed directive (because dist/
-// is at web/dist/), and hands the FS over via SetDistFS so the sub package
+// is at internal/web/dist/), and hands the FS over via SetDistFS so the sub package
 // doesn't import web — that would create an import cycle once any
-// web/controller handler reuses sub's link-building service.
+// internal/web/controller handler reuses sub's link-building service.
 var distFS embed.FS
 
 // SetDistFS installs the embedded frontend filesystem the sub server uses

+ 5 - 5
sub/subJsonService.go → internal/sub/json_service.go

@@ -7,11 +7,11 @@ import (
 	"maps"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/util/json_util"
-	"github.com/mhsanaei/3x-ui/v3/util/random"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 //go:embed default.json

+ 1 - 1
sub/subJsonService_test.go → internal/sub/json_service_test.go

@@ -4,7 +4,7 @@ import (
 	"encoding/json"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func hasDirectOutOutbound(svc *SubJsonService) bool {

+ 2 - 2
sub/links.go → internal/sub/links.go

@@ -3,8 +3,8 @@ package sub
 import (
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
 type LinkProvider struct {

+ 0 - 0
sub/links_test.go → internal/sub/links_test.go


+ 9 - 9
sub/subService.go → internal/sub/service.go

@@ -15,13 +15,13 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/goccy/go-json"
 
-	"github.com/mhsanaei/3x-ui/v3/database"
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/util/common"
-	"github.com/mhsanaei/3x-ui/v3/util/random"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/random"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 // SubService provides business logic for generating subscription links and managing subscription data.
@@ -991,7 +991,7 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
 
 // pinnedSha256List extracts tlsSettings.settings.pinnedPeerCertSha256 as a
 // []string. The field is panel-only (stripped before the run-config reaches
-// xray-core via web/service/xray.go) but flows into share links so clients
+// xray-core via internal/web/service/xray.go) but flows into share links so clients
 // can pin the server's certificate hash.
 func pinnedSha256List(tlsClientSettings any) ([]string, bool) {
 	raw, ok := searchKey(tlsClientSettings, "pinnedPeerCertSha256")
@@ -1025,7 +1025,7 @@ func pinnedSha256List(tlsClientSettings any) ([]string, bool) {
 // it. Hysteria2 clients hex-decode pinSHA256 and crash on a base64 value, so
 // each entry is coerced to bare hex here. Anything that is neither a 32-byte
 // hex nor a 32-byte base64 SHA-256 is returned unchanged so unexpected data is
-// not silently dropped. Mirrors decodeCertPin in web/service/node.go.
+// not silently dropped. Mirrors decodeCertPin in internal/web/service/node.go.
 func hysteriaPinHex(pin string) string {
 	pin = strings.TrimSpace(pin)
 	if h := strings.ReplaceAll(pin, ":", ""); len(h) == hex.EncodedLen(sha256.Size) {

+ 1 - 1
sub/subService_test.go → internal/sub/service_test.go

@@ -6,7 +6,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func TestSubscriptionExpiryFromClient(t *testing.T) {

+ 3 - 3
sub/subService_userinfo_test.go → internal/sub/service_userinfo_test.go

@@ -4,9 +4,9 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/mhsanaei/3x-ui/v3/database"
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/xray"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 func TestAggregateTrafficByEmails_FallsBackToClientLimits(t *testing.T) {

+ 8 - 8
sub/sub.go → internal/sub/sub.go

@@ -14,12 +14,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/util/common"
-	"github.com/mhsanaei/3x-ui/v3/web/locale"
-	"github.com/mhsanaei/3x-ui/v3/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/web/network"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/network"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 
 	"github.com/gin-gonic/gin"
 )
@@ -191,8 +191,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	}
 
 	var assetsFS http.FileSystem
-	if _, err := os.Stat("web/dist/assets"); err == nil {
-		assetsFS = http.FS(os.DirFS("web/dist/assets"))
+	if _, err := os.Stat("internal/web/dist/assets"); err == nil {
+		assetsFS = http.FS(os.DirFS("internal/web/dist/assets"))
 	} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
 		assetsFS = http.FS(subFS)
 	} else {

+ 1 - 1
util/common/err.go → internal/util/common/err.go

@@ -5,7 +5,7 @@ import (
 	"errors"
 	"fmt"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
 
 // NewErrorf creates a new error with formatted message.

+ 0 - 0
util/common/format.go → internal/util/common/format.go


+ 0 - 0
util/common/format_test.go → internal/util/common/format_test.go


+ 0 - 0
util/common/multi_error.go → internal/util/common/multi_error.go


+ 0 - 0
util/common/multi_error_test.go → internal/util/common/multi_error_test.go


+ 0 - 0
util/crypto/crypto.go → internal/util/crypto/crypto.go


+ 0 - 0
util/crypto/crypto_test.go → internal/util/crypto/crypto_test.go


+ 0 - 0
util/json_util/json.go → internal/util/json_util/json.go


+ 0 - 0
util/json_util/json_test.go → internal/util/json_util/json_test.go


+ 0 - 0
util/ldap/ldap.go → internal/util/ldap/ldap.go


+ 0 - 0
util/link/outbound.go → internal/util/link/outbound.go


+ 0 - 0
util/link/outbound_test.go → internal/util/link/outbound_test.go


+ 0 - 0
util/netproxy/netproxy.go → internal/util/netproxy/netproxy.go


+ 0 - 0
util/netproxy/netproxy_test.go → internal/util/netproxy/netproxy_test.go


+ 0 - 0
util/netsafe/netsafe.go → internal/util/netsafe/netsafe.go


+ 0 - 0
util/netsafe/netsafe_test.go → internal/util/netsafe/netsafe_test.go


+ 0 - 0
util/random/random.go → internal/util/random/random.go


+ 0 - 0
util/random/random_test.go → internal/util/random/random_test.go


+ 0 - 0
util/reflect_util/reflect.go → internal/util/reflect_util/reflect.go


+ 0 - 0
util/sys/psutil.go → internal/util/sys/psutil.go


+ 0 - 0
util/sys/sys_darwin.go → internal/util/sys/sys_darwin.go


+ 0 - 0
util/sys/sys_linux.go → internal/util/sys/sys_linux.go


+ 0 - 0
util/sys/sys_windows.go → internal/util/sys/sys_windows.go


+ 1 - 1
util/wireguard.go → internal/util/wireguard/wireguard.go

@@ -1,4 +1,4 @@
-package util
+package wireguard
 
 import (
 	"crypto/rand"

+ 11 - 8
web/controller/api.go → internal/web/controller/api.go

@@ -4,9 +4,12 @@ import (
 	"net/http"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/web/session"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 
 	"github.com/gin-gonic/gin"
 )
@@ -20,13 +23,13 @@ type APIController struct {
 	settingController     *SettingController
 	xraySettingController *XraySettingController
 	settingService        service.SettingService
-	userService           service.UserService
-	apiTokenService       service.ApiTokenService
-	Tgbot                 service.Tgbot
+	userService           panel.UserService
+	apiTokenService       panel.ApiTokenService
+	Tgbot                 tgbot.Tgbot
 }
 
 // NewAPIController creates a new APIController instance and initializes its routes.
-func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *APIController {
+func NewAPIController(g *gin.RouterGroup, customGeo *integration.CustomGeoService) *APIController {
 	a := &APIController{}
 	a.initRouter(g, customGeo)
 	return a
@@ -57,7 +60,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
 }
 
 // initRouter sets up the API routes for inbounds, server, and other endpoints.
-func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.CustomGeoService) {
+func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *integration.CustomGeoService) {
 	// Main API group
 	api := g.Group("/panel/api")
 	api.Use(a.checkAPIAuth)

+ 2 - 2
web/controller/api_docs_test.go → internal/web/controller/api_docs_test.go

@@ -29,7 +29,7 @@ func buildDocSet(t *testing.T) map[string]bool {
 	if err != nil {
 		t.Fatalf("failed to get current dir: %v", err)
 	}
-	endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
+	endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
 	data, err := os.ReadFile(endpointsPath)
 	if err != nil {
 		t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
@@ -81,7 +81,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
 		switch entry.Name() {
 		case "index.go":
 			basePath = ""
-		case "xui.go":
+		case "spa.go":
 			basePath = "/panel"
 		case "api.go":
 			basePath = "/panel/api"

+ 3 - 3
web/controller/base.go → internal/web/controller/base.go

@@ -5,9 +5,9 @@ package controller
 import (
 	"net/http"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/locale"
-	"github.com/mhsanaei/3x-ui/v3/web/session"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 
 	"github.com/gin-gonic/gin"
 )

+ 3 - 3
web/controller/client.go → internal/web/controller/client.go

@@ -7,9 +7,9 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
 
 	"github.com/gin-gonic/gin"
 )

+ 19 - 19
web/controller/custom_geo.go → internal/web/controller/custom_geo.go

@@ -5,20 +5,20 @@ import (
 	"net/http"
 	"strconv"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/entity"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
 
 	"github.com/gin-gonic/gin"
 )
 
 type CustomGeoController struct {
 	BaseController
-	customGeoService *service.CustomGeoService
+	customGeoService *integration.CustomGeoService
 }
 
-func NewCustomGeoController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *CustomGeoController {
+func NewCustomGeoController(g *gin.RouterGroup, customGeo *integration.CustomGeoService) *CustomGeoController {
 	a := &CustomGeoController{customGeoService: customGeo}
 	a.initRouter(g)
 	return a
@@ -39,33 +39,33 @@ func mapCustomGeoErr(c *gin.Context, err error) error {
 		return nil
 	}
 	switch {
-	case errors.Is(err, service.ErrCustomGeoInvalidType):
+	case errors.Is(err, integration.ErrCustomGeoInvalidType):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidType"))
-	case errors.Is(err, service.ErrCustomGeoAliasRequired):
+	case errors.Is(err, integration.ErrCustomGeoAliasRequired):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasRequired"))
-	case errors.Is(err, service.ErrCustomGeoAliasPattern):
+	case errors.Is(err, integration.ErrCustomGeoAliasPattern):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern"))
-	case errors.Is(err, service.ErrCustomGeoAliasReserved):
+	case errors.Is(err, integration.ErrCustomGeoAliasReserved):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasReserved"))
-	case errors.Is(err, service.ErrCustomGeoURLRequired):
+	case errors.Is(err, integration.ErrCustomGeoURLRequired):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlRequired"))
-	case errors.Is(err, service.ErrCustomGeoInvalidURL):
+	case errors.Is(err, integration.ErrCustomGeoInvalidURL):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidUrl"))
-	case errors.Is(err, service.ErrCustomGeoURLScheme):
+	case errors.Is(err, integration.ErrCustomGeoURLScheme):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlScheme"))
-	case errors.Is(err, service.ErrCustomGeoURLHost):
+	case errors.Is(err, integration.ErrCustomGeoURLHost):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
-	case errors.Is(err, service.ErrCustomGeoDuplicateAlias):
+	case errors.Is(err, integration.ErrCustomGeoDuplicateAlias):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrDuplicateAlias"))
-	case errors.Is(err, service.ErrCustomGeoNotFound):
+	case errors.Is(err, integration.ErrCustomGeoNotFound):
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrNotFound"))
-	case errors.Is(err, service.ErrCustomGeoDownload):
+	case errors.Is(err, integration.ErrCustomGeoDownload):
 		logger.Warning("custom geo download:", err)
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
-	case errors.Is(err, service.ErrCustomGeoSSRFBlocked):
+	case errors.Is(err, integration.ErrCustomGeoSSRFBlocked):
 		logger.Warning("custom geo SSRF blocked:", err)
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
-	case errors.Is(err, service.ErrCustomGeoPathTraversal):
+	case errors.Is(err, integration.ErrCustomGeoPathTraversal):
 		logger.Warning("custom geo path traversal blocked:", err)
 		return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
 	default:

+ 3 - 3
web/controller/dist.go → internal/web/controller/dist.go

@@ -11,9 +11,9 @@ import (
 
 	"github.com/gin-gonic/gin"
 
-	"github.com/mhsanaei/3x-ui/v3/config"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/session"
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 )
 
 var distFS embed.FS

+ 0 - 0
web/controller/dist_test.go → internal/web/controller/dist_test.go


+ 2 - 2
web/controller/group.go → internal/web/controller/group.go

@@ -3,8 +3,8 @@ package controller
 import (
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/util/common"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 
 	"github.com/gin-gonic/gin"
 )

+ 5 - 5
web/controller/inbound.go → internal/web/controller/inbound.go

@@ -6,11 +6,11 @@ import (
 	"strconv"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/web/session"
-	"github.com/mhsanaei/3x-ui/v3/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
 
 	"github.com/gin-gonic/gin"
 )

+ 14 - 12
web/controller/index.go → internal/web/controller/index.go

@@ -5,10 +5,12 @@ import (
 	"text/template"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/web/session"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 
 	"github.com/gin-gonic/gin"
 )
@@ -25,8 +27,8 @@ type IndexController struct {
 	BaseController
 
 	settingService service.SettingService
-	userService    service.UserService
-	tgbot          service.Tgbot
+	userService    panel.UserService
+	tgbot          tgbot.Tgbot
 }
 
 // NewIndexController creates a new IndexController and initializes its routes.
@@ -79,11 +81,11 @@ func (a *IndexController) login(c *gin.Context) {
 	if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
 		reason := "too many failed attempts"
 		logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
-		a.tgbot.UserLoginNotify(service.LoginAttempt{
+		a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 			Username: safeUser,
 			IP:       remoteIP,
 			Time:     timeStr,
-			Status:   service.LoginFail,
+			Status:   tgbot.LoginFail,
 			Reason:   reason,
 		})
 		pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
@@ -99,11 +101,11 @@ func (a *IndexController) login(c *gin.Context) {
 		} else {
 			logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
 		}
-		a.tgbot.UserLoginNotify(service.LoginAttempt{
+		a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 			Username: safeUser,
 			IP:       remoteIP,
 			Time:     timeStr,
-			Status:   service.LoginFail,
+			Status:   tgbot.LoginFail,
 			Reason:   reason,
 		})
 		pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
@@ -112,11 +114,11 @@ func (a *IndexController) login(c *gin.Context) {
 
 	defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
 	logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
-	a.tgbot.UserLoginNotify(service.LoginAttempt{
+	a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
 		Username: safeUser,
 		IP:       remoteIP,
 		Time:     timeStr,
-		Status:   service.LoginSuccess,
+		Status:   tgbot.LoginSuccess,
 	})
 
 	if err := session.SetLoginUser(c, user); err != nil {

+ 0 - 0
web/controller/login_limiter.go → internal/web/controller/login_limiter.go


+ 0 - 0
web/controller/login_limiter_test.go → internal/web/controller/login_limiter_test.go


+ 3 - 3
web/controller/node.go → internal/web/controller/node.go

@@ -8,9 +8,9 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 
 	"github.com/gin-gonic/gin"
 )

+ 9 - 8
web/controller/server.go → internal/web/controller/server.go

@@ -8,13 +8,14 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/database"
-	"github.com/mhsanaei/3x-ui/v3/database/model"
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/entity"
-	"github.com/mhsanaei/3x-ui/v3/web/global"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/global"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
 
 	"github.com/gin-gonic/gin"
 )
@@ -27,7 +28,7 @@ type ServerController struct {
 
 	serverService      service.ServerService
 	settingService     service.SettingService
-	panelService       service.PanelService
+	panelService       panel.PanelService
 	xrayMetricsService service.XrayMetricsService
 }
 

+ 9 - 8
web/controller/setting.go → internal/web/controller/setting.go

@@ -5,11 +5,12 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/util/crypto"
-	"github.com/mhsanaei/3x-ui/v3/web/entity"
-	"github.com/mhsanaei/3x-ui/v3/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/web/session"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 
 	"github.com/gin-gonic/gin"
 )
@@ -25,9 +26,9 @@ type updateUserForm struct {
 // SettingController handles settings and user management operations.
 type SettingController struct {
 	settingService  service.SettingService
-	userService     service.UserService
-	panelService    service.PanelService
-	apiTokenService service.ApiTokenService
+	userService     panel.UserService
+	panelService    panel.PanelService
+	apiTokenService panel.ApiTokenService
 }
 
 // NewSettingController creates a new SettingController and initializes its routes.

+ 3 - 3
web/controller/xui.go → internal/web/controller/spa.go

@@ -3,9 +3,9 @@ package controller
 import (
 	"net/http"
 
-	"github.com/mhsanaei/3x-ui/v3/web/entity"
-	"github.com/mhsanaei/3x-ui/v3/web/middleware"
-	"github.com/mhsanaei/3x-ui/v3/web/session"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 
 	"github.com/gin-gonic/gin"
 )

+ 3 - 3
web/controller/util.go → internal/web/controller/util.go

@@ -9,9 +9,9 @@ import (
 	"runtime"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/entity"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 
 	"github.com/gin-gonic/gin"
 )

+ 0 - 0
web/controller/util_test.go → internal/web/controller/util_test.go


+ 6 - 6
web/controller/websocket.go → internal/web/controller/websocket.go

@@ -6,9 +6,9 @@ import (
 	"net/url"
 	"strings"
 
-	"github.com/mhsanaei/3x-ui/v3/logger"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
-	"github.com/mhsanaei/3x-ui/v3/web/session"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 
 	"github.com/gin-gonic/gin"
 	ws "github.com/gorilla/websocket"
@@ -49,14 +49,14 @@ func checkSameOrigin(r *http.Request) bool {
 
 // WebSocketController handles the HTTP→WebSocket upgrade for real-time updates.
 // All per-connection lifecycle (pumps, hub registration) lives in
-// service.WebSocketService — this controller is HTTP-layer only.
+// panel.WebSocketService — this controller is HTTP-layer only.
 type WebSocketController struct {
 	BaseController
-	service *service.WebSocketService
+	service *panel.WebSocketService
 }
 
 // NewWebSocketController creates a controller wired to the given service.
-func NewWebSocketController(svc *service.WebSocketService) *WebSocketController {
+func NewWebSocketController(svc *panel.WebSocketService) *WebSocketController {
 	return &WebSocketController{service: svc}
 }
 

+ 7 - 5
web/controller/xray_setting.go → internal/web/controller/xray_setting.go

@@ -6,8 +6,10 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/util/common"
-	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
 
 	"github.com/gin-gonic/gin"
 )
@@ -17,10 +19,10 @@ type XraySettingController struct {
 	XraySettingService          service.XraySettingService
 	SettingService              service.SettingService
 	InboundService              service.InboundService
-	OutboundService             service.OutboundService
+	OutboundService             outbound.OutboundService
 	XrayService                 service.XrayService
-	WarpService                 service.WarpService
-	NordService                 service.NordService
+	WarpService                 integration.WarpService
+	NordService                 integration.NordService
 	OutboundSubscriptionService service.OutboundSubscriptionService
 }
 

+ 1 - 1
web/entity/entity.go → internal/web/entity/entity.go

@@ -8,7 +8,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 )
 
 // Msg represents a standard API response message with success status, message text, and optional data object.

Some files were not shown because too many files changed in this diff