mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
41645255f1
* 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.
1022 lines
28 KiB
Go
1022 lines
28 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/xray"
|
|
)
|
|
|
|
// delInboundClients removes several clients from a single inbound in one pass:
|
|
// one settings rewrite, one runtime sweep, one Save and one SyncInbound for the
|
|
// whole batch, instead of repeating the full per-client cycle. It mirrors the
|
|
// semantics of DelInboundClientByEmail for each removed client. needRestart is
|
|
// the OR across all removals.
|
|
func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId int, recs []*model.ClientRecord, keepTraffic bool) (bool, error) {
|
|
if len(recs) == 0 {
|
|
return false, nil
|
|
}
|
|
defer lockInbound(inboundId).Unlock()
|
|
|
|
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
|
if err != nil {
|
|
logger.Error("Load Old Data Error")
|
|
return false, err
|
|
}
|
|
|
|
var settings map[string]any
|
|
if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Match by email — the client's stable identity (see Delete). Removes every
|
|
// entry carrying a wanted email, independent of credential drift.
|
|
wanted := make(map[string]struct{}, len(recs))
|
|
for _, rec := range recs {
|
|
if rec.Email != "" {
|
|
wanted[rec.Email] = struct{}{}
|
|
}
|
|
}
|
|
|
|
interfaceClients, ok := settings["clients"].([]any)
|
|
if !ok {
|
|
return false, common.NewError("invalid clients format in inbound settings")
|
|
}
|
|
|
|
type removedClient struct {
|
|
email string
|
|
needApiDel bool
|
|
}
|
|
removed := make([]removedClient, 0, len(wanted))
|
|
newClients := make([]any, 0, len(interfaceClients))
|
|
for _, client := range interfaceClients {
|
|
c, ok := client.(map[string]any)
|
|
if !ok {
|
|
newClients = append(newClients, client)
|
|
continue
|
|
}
|
|
email, _ := c["email"].(string)
|
|
if _, hit := wanted[email]; hit && email != "" {
|
|
enable, _ := c["enable"].(bool)
|
|
removed = append(removed, removedClient{email: email, needApiDel: enable})
|
|
continue
|
|
}
|
|
newClients = append(newClients, client)
|
|
}
|
|
|
|
if len(removed) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
db := database.GetDB()
|
|
newClients = compactOrphans(db, newClients)
|
|
if newClients == nil {
|
|
newClients = []any{}
|
|
}
|
|
settings["clients"] = newClients
|
|
newSettings, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
oldInbound.Settings = string(newSettings)
|
|
|
|
var sharedSet map[string]bool
|
|
if !keepTraffic {
|
|
removedEmails := make([]string, 0, len(removed))
|
|
for _, r := range removed {
|
|
if r.email != "" {
|
|
removedEmails = append(removedEmails, r.email)
|
|
}
|
|
}
|
|
var sharedErr error
|
|
sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(removedEmails, inboundId)
|
|
if sharedErr != nil {
|
|
return false, sharedErr
|
|
}
|
|
}
|
|
|
|
needRestart := false
|
|
markDirty := false
|
|
for _, r := range removed {
|
|
email := r.email
|
|
emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
|
|
if !emailShared && !keepTraffic {
|
|
if err := inboundSvc.DelClientIPs(db, email); err != nil {
|
|
logger.Error("Error in delete client IPs")
|
|
return needRestart, err
|
|
}
|
|
}
|
|
if len(email) > 0 {
|
|
var enables []bool
|
|
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error; err != nil {
|
|
logger.Error("Get stats error")
|
|
return needRestart, err
|
|
}
|
|
notDepleted := len(enables) > 0 && enables[0]
|
|
if !emailShared && !keepTraffic {
|
|
if err := inboundSvc.DelClientStat(db, email); err != nil {
|
|
logger.Error("Delete stats Data Error")
|
|
return needRestart, err
|
|
}
|
|
}
|
|
if r.needApiDel && notDepleted && oldInbound.NodeID == nil {
|
|
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
|
if rterr != nil {
|
|
needRestart = true
|
|
} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 != nil {
|
|
if !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
|
needRestart = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if oldInbound.NodeID != nil && len(email) > 0 {
|
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
|
if perr != nil {
|
|
return needRestart, perr
|
|
}
|
|
if dirty {
|
|
markDirty = true
|
|
}
|
|
if push {
|
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
|
markDirty = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := db.Save(oldInbound).Error; err != nil {
|
|
return needRestart, err
|
|
}
|
|
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
|
if gcErr != nil {
|
|
return needRestart, gcErr
|
|
}
|
|
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
|
return needRestart, err
|
|
}
|
|
if markDirty && oldInbound.NodeID != nil {
|
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
|
logger.Warning("mark node dirty failed:", dErr)
|
|
}
|
|
}
|
|
return needRestart, nil
|
|
}
|
|
|
|
func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client, emailSubIDs map[string]string) (string, error) {
|
|
if emailSubIDs == nil {
|
|
var err error
|
|
emailSubIDs, err = inboundSvc.getAllEmailSubIDs()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
seen := make(map[string]string, len(clients))
|
|
for _, client := range clients {
|
|
if client.Email == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(client.Email)
|
|
if prev, ok := seen[key]; ok {
|
|
if prev != client.SubID || client.SubID == "" {
|
|
return client.Email, nil
|
|
}
|
|
continue
|
|
}
|
|
seen[key] = client.SubID
|
|
if existingSub, ok := emailSubIDs[key]; ok {
|
|
if client.SubID == "" || existingSub == "" || existingSub != client.SubID {
|
|
return client.Email, nil
|
|
}
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
|
|
return s.addInboundClient(inboundSvc, data, nil)
|
|
}
|
|
|
|
// addInboundClient is AddInboundClient with an optional precomputed email→subId
|
|
// map. Bulk callers pass a single snapshot so the global getAllEmailSubIDs scan
|
|
// runs once for the whole batch instead of once per target inbound; a nil map
|
|
// makes it compute its own (the single-add path).
|
|
func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model.Inbound, emailSubIDs map[string]string) (bool, error) {
|
|
defer lockInbound(data.Id).Unlock()
|
|
|
|
clients, err := inboundSvc.GetClients(data)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
var settings map[string]any
|
|
err = json.Unmarshal([]byte(data.Settings), &settings)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
interfaceClients := settings["clients"].([]any)
|
|
nowTs := time.Now().Unix() * 1000
|
|
for i := range interfaceClients {
|
|
if cm, ok := interfaceClients[i].(map[string]any); ok {
|
|
if _, ok2 := cm["created_at"]; !ok2 {
|
|
cm["created_at"] = nowTs
|
|
}
|
|
cm["updated_at"] = nowTs
|
|
existingSub, _ := cm["subId"].(string)
|
|
if strings.TrimSpace(existingSub) == "" {
|
|
cm["subId"] = random.NumLower(16)
|
|
}
|
|
interfaceClients[i] = cm
|
|
}
|
|
}
|
|
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, emailSubIDs)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if existEmail != "" {
|
|
return false, common.NewError("Duplicate email:", existEmail)
|
|
}
|
|
|
|
oldInbound, err := inboundSvc.GetInbound(data.Id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, client := range clients {
|
|
if strings.TrimSpace(client.Email) == "" {
|
|
return false, common.NewError("client email is required")
|
|
}
|
|
switch oldInbound.Protocol {
|
|
case "trojan":
|
|
if client.Password == "" {
|
|
return false, common.NewError("empty client ID")
|
|
}
|
|
case "shadowsocks":
|
|
if client.Email == "" {
|
|
return false, common.NewError("empty client ID")
|
|
}
|
|
case "hysteria":
|
|
if client.Auth == "" {
|
|
return false, common.NewError("empty client ID")
|
|
}
|
|
default:
|
|
if client.ID == "" {
|
|
return false, common.NewError("empty client ID")
|
|
}
|
|
}
|
|
}
|
|
|
|
var oldSettings map[string]any
|
|
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if oldInbound.Protocol == model.Shadowsocks {
|
|
applyShadowsocksClientMethod(interfaceClients, oldSettings)
|
|
}
|
|
|
|
oldClients := oldSettings["clients"].([]any)
|
|
oldClients = compactOrphans(database.GetDB(), oldClients)
|
|
oldClients = append(oldClients, interfaceClients...)
|
|
|
|
oldSettings["clients"] = oldClients
|
|
|
|
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
oldInbound.Settings = string(newSettings)
|
|
|
|
db := database.GetDB()
|
|
tx := db.Begin()
|
|
|
|
markDirty := false
|
|
defer func() {
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return
|
|
}
|
|
tx.Commit()
|
|
if markDirty && oldInbound.NodeID != nil {
|
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
|
logger.Warning("mark node dirty failed:", dErr)
|
|
}
|
|
}
|
|
}()
|
|
|
|
needRestart := false
|
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
|
if perr != nil {
|
|
err = perr
|
|
return false, err
|
|
}
|
|
if dirty {
|
|
markDirty = true
|
|
}
|
|
if oldInbound.NodeID == nil {
|
|
if !push {
|
|
needRestart = true
|
|
} else {
|
|
for _, client := range clients {
|
|
if len(client.Email) == 0 {
|
|
needRestart = true
|
|
continue
|
|
}
|
|
inboundSvc.AddClientStat(tx, data.Id, &client)
|
|
if !client.Enable {
|
|
continue
|
|
}
|
|
cipher := ""
|
|
if oldInbound.Protocol == "shadowsocks" {
|
|
cipher = oldSettings["method"].(string)
|
|
}
|
|
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
|
"email": client.Email,
|
|
"id": client.ID,
|
|
"auth": client.Auth,
|
|
"security": client.Security,
|
|
"flow": client.Flow,
|
|
"password": client.Password,
|
|
"cipher": cipher,
|
|
})
|
|
if err1 == nil {
|
|
logger.Debug("Client added on", rt.Name(), ":", client.Email)
|
|
} else {
|
|
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
|
needRestart = true
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for _, client := range clients {
|
|
if len(client.Email) > 0 {
|
|
inboundSvc.AddClientStat(tx, data.Id, &client)
|
|
}
|
|
if push {
|
|
if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
|
|
logger.Warning("Error in adding client on", rt.Name(), ":", err1)
|
|
markDirty = true
|
|
push = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err = tx.Save(oldInbound).Error; err != nil {
|
|
return false, err
|
|
}
|
|
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
|
if gcErr != nil {
|
|
err = gcErr
|
|
return false, err
|
|
}
|
|
if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
|
|
return false, err
|
|
}
|
|
return needRestart, nil
|
|
}
|
|
|
|
func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *model.Inbound, oldEmail string) (bool, error) {
|
|
defer lockInbound(data.Id).Unlock()
|
|
|
|
clients, err := inboundSvc.GetClients(data)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
var settings map[string]any
|
|
err = json.Unmarshal([]byte(data.Settings), &settings)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
interfaceClients := settings["clients"].([]any)
|
|
|
|
oldInbound, err := inboundSvc.GetInbound(data.Id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
oldClients, err := inboundSvc.GetClients(oldInbound)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
newClientId := ""
|
|
switch oldInbound.Protocol {
|
|
case "trojan":
|
|
newClientId = clients[0].Password
|
|
case "shadowsocks":
|
|
newClientId = clients[0].Email
|
|
case "hysteria":
|
|
newClientId = clients[0].Auth
|
|
default:
|
|
newClientId = clients[0].ID
|
|
}
|
|
|
|
// Locate the client to replace by email — the client's stable identity.
|
|
// Credentials (uuid/password/auth) can drift from the inbound JSON, so they
|
|
// are never used for matching.
|
|
clientIndex := -1
|
|
for index, oldClient := range oldClients {
|
|
if strings.EqualFold(oldClient.Email, oldEmail) {
|
|
oldEmail = oldClient.Email
|
|
clientIndex = index
|
|
break
|
|
}
|
|
}
|
|
|
|
if newClientId == "" || clientIndex == -1 {
|
|
return false, common.NewError("empty client ID")
|
|
}
|
|
if strings.TrimSpace(clients[0].Email) == "" {
|
|
return false, common.NewError("client email is required")
|
|
}
|
|
|
|
if clients[0].Email != oldEmail {
|
|
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if existEmail != "" {
|
|
return false, common.NewError("Duplicate email:", existEmail)
|
|
}
|
|
}
|
|
|
|
var oldSettings map[string]any
|
|
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
settingsClients := oldSettings["clients"].([]any)
|
|
var preservedCreated any
|
|
var preservedSubID string
|
|
if clientIndex >= 0 && clientIndex < len(settingsClients) {
|
|
if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok {
|
|
if v, ok2 := oldMap["created_at"]; ok2 {
|
|
preservedCreated = v
|
|
}
|
|
preservedSubID, _ = oldMap["subId"].(string)
|
|
}
|
|
}
|
|
if len(interfaceClients) > 0 {
|
|
if newMap, ok := interfaceClients[0].(map[string]any); ok {
|
|
if preservedCreated == nil {
|
|
preservedCreated = time.Now().Unix() * 1000
|
|
}
|
|
newMap["created_at"] = preservedCreated
|
|
newMap["updated_at"] = time.Now().Unix() * 1000
|
|
newSub, _ := newMap["subId"].(string)
|
|
if strings.TrimSpace(newSub) == "" {
|
|
if strings.TrimSpace(preservedSubID) != "" {
|
|
newMap["subId"] = preservedSubID
|
|
} else {
|
|
newMap["subId"] = random.NumLower(16)
|
|
}
|
|
}
|
|
interfaceClients[0] = newMap
|
|
}
|
|
}
|
|
if oldInbound.Protocol == model.Shadowsocks {
|
|
applyShadowsocksClientMethod(interfaceClients, oldSettings)
|
|
}
|
|
settingsClients[clientIndex] = interfaceClients[0]
|
|
oldSettings["clients"] = settingsClients
|
|
|
|
if oldInbound.Protocol == model.VLESS {
|
|
hasVisionFlow := false
|
|
for _, c := range settingsClients {
|
|
cm, ok := c.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" {
|
|
hasVisionFlow = true
|
|
break
|
|
}
|
|
}
|
|
if !hasVisionFlow {
|
|
delete(oldSettings, "testseed")
|
|
}
|
|
}
|
|
|
|
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
oldInbound.Settings = string(newSettings)
|
|
db := database.GetDB()
|
|
tx := db.Begin()
|
|
|
|
markDirty := false
|
|
defer func() {
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return
|
|
}
|
|
tx.Commit()
|
|
if markDirty && oldInbound.NodeID != nil {
|
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
|
logger.Warning("mark node dirty failed:", dErr)
|
|
}
|
|
}
|
|
}()
|
|
|
|
if len(clients[0].Email) > 0 {
|
|
if len(oldEmail) > 0 {
|
|
emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email)
|
|
targetExists := int64(0)
|
|
if !emailUnchanged {
|
|
if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Count(&targetExists).Error; err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
if emailUnchanged || targetExists == 0 {
|
|
err = inboundSvc.UpdateClientStat(tx, oldEmail, &clients[0])
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
err = inboundSvc.UpdateClientIPs(tx, oldEmail, clients[0].Email)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
} else {
|
|
stillUsed, sErr := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
|
|
if sErr != nil {
|
|
return false, sErr
|
|
}
|
|
if !stillUsed {
|
|
if err = inboundSvc.DelClientStat(tx, oldEmail); err != nil {
|
|
return false, err
|
|
}
|
|
if err = inboundSvc.DelClientIPs(tx, oldEmail); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
if err = inboundSvc.UpdateClientStat(tx, clients[0].Email, &clients[0]); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
} else {
|
|
inboundSvc.AddClientStat(tx, data.Id, &clients[0])
|
|
}
|
|
} else {
|
|
stillUsed, err := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !stillUsed {
|
|
err = inboundSvc.DelClientStat(tx, oldEmail)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
err = inboundSvc.DelClientIPs(tx, oldEmail)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
}
|
|
needRestart := false
|
|
if len(oldEmail) > 0 {
|
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
|
if perr != nil {
|
|
err = perr
|
|
return false, err
|
|
}
|
|
if dirty {
|
|
markDirty = true
|
|
}
|
|
if oldInbound.NodeID == nil {
|
|
if !push {
|
|
needRestart = true
|
|
} else {
|
|
if oldClients[clientIndex].Enable {
|
|
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
|
|
if err1 == nil {
|
|
logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
|
|
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
|
|
logger.Debug("User is already deleted. Nothing to do more...")
|
|
} else {
|
|
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
|
|
needRestart = true
|
|
}
|
|
}
|
|
if clients[0].Enable {
|
|
cipher := ""
|
|
if oldInbound.Protocol == "shadowsocks" {
|
|
cipher = oldSettings["method"].(string)
|
|
}
|
|
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
|
"email": clients[0].Email,
|
|
"id": clients[0].ID,
|
|
"security": clients[0].Security,
|
|
"flow": clients[0].Flow,
|
|
"auth": clients[0].Auth,
|
|
"password": clients[0].Password,
|
|
"cipher": cipher,
|
|
})
|
|
if err1 == nil {
|
|
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
|
|
} else {
|
|
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
|
needRestart = true
|
|
}
|
|
}
|
|
}
|
|
} else if push {
|
|
if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
|
|
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
|
|
markDirty = true
|
|
}
|
|
}
|
|
} else {
|
|
logger.Debug("Client old email not found")
|
|
needRestart = true
|
|
}
|
|
if err = tx.Save(oldInbound).Error; err != nil {
|
|
return false, err
|
|
}
|
|
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
|
if gcErr != nil {
|
|
err = gcErr
|
|
return false, err
|
|
}
|
|
if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
|
|
return false, err
|
|
}
|
|
return needRestart, nil
|
|
}
|
|
|
|
func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string, keepTraffic bool) (bool, error) {
|
|
defer lockInbound(inboundId).Unlock()
|
|
|
|
oldInbound, err := inboundSvc.GetInbound(inboundId)
|
|
if err != nil {
|
|
logger.Error("Load Old Data Error")
|
|
return false, err
|
|
}
|
|
|
|
var settings map[string]any
|
|
if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
interfaceClients, ok := settings["clients"].([]any)
|
|
if !ok {
|
|
return false, common.NewError("invalid clients format in inbound settings")
|
|
}
|
|
|
|
var newClients []any
|
|
needApiDel := false
|
|
found := false
|
|
|
|
for _, client := range interfaceClients {
|
|
c, ok := client.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if cEmail, ok := c["email"].(string); ok && cEmail == email {
|
|
found = true
|
|
needApiDel, _ = c["enable"].(bool)
|
|
} else {
|
|
newClients = append(newClients, client)
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return false, fmt.Errorf("%w for email: %s", ErrClientNotInInbound, email)
|
|
}
|
|
db := database.GetDB()
|
|
newClients = compactOrphans(db, newClients)
|
|
if newClients == nil {
|
|
newClients = []any{}
|
|
}
|
|
settings["clients"] = newClients
|
|
newSettings, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
oldInbound.Settings = string(newSettings)
|
|
|
|
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if !emailShared && !keepTraffic {
|
|
if err := inboundSvc.DelClientIPs(db, email); err != nil {
|
|
logger.Error("Error in delete client IPs")
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
needRestart := false
|
|
markDirty := false
|
|
|
|
if len(email) > 0 && !emailShared {
|
|
if !keepTraffic {
|
|
traffic, err := inboundSvc.GetClientTrafficByEmail(email)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if traffic != nil {
|
|
if err := inboundSvc.DelClientStat(db, email); err != nil {
|
|
logger.Error("Delete stats Data Error")
|
|
return false, err
|
|
}
|
|
}
|
|
}
|
|
|
|
if needApiDel {
|
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
|
if perr != nil {
|
|
return false, perr
|
|
}
|
|
if dirty {
|
|
markDirty = true
|
|
}
|
|
if oldInbound.NodeID == nil {
|
|
if !push {
|
|
needRestart = true
|
|
} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
|
|
logger.Debug("Client deleted on", rt.Name(), ":", email)
|
|
needRestart = false
|
|
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
|
logger.Debug("User is already deleted. Nothing to do more...")
|
|
} else {
|
|
logger.Debug("Error in deleting client on", rt.Name(), ":", email)
|
|
needRestart = true
|
|
}
|
|
} else if push {
|
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
|
markDirty = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := db.Save(oldInbound).Error; err != nil {
|
|
return false, err
|
|
}
|
|
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
|
if gcErr != nil {
|
|
return false, gcErr
|
|
}
|
|
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
|
return false, err
|
|
}
|
|
if markDirty && oldInbound.NodeID != nil {
|
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
|
logger.Warning("mark node dirty failed:", dErr)
|
|
}
|
|
}
|
|
return needRestart, nil
|
|
}
|
|
|
|
func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, trafficId int, tgId int64) (bool, error) {
|
|
traffic, inbound, err := inboundSvc.GetClientInboundByTrafficID(trafficId)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if inbound == nil {
|
|
return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId)
|
|
}
|
|
|
|
clientEmail := traffic.Email
|
|
|
|
oldClients, err := inboundSvc.GetClients(inbound)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
found := false
|
|
for _, oldClient := range oldClients {
|
|
if oldClient.Email == clientEmail {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return false, common.NewError("Client Not Found For Email:", clientEmail)
|
|
}
|
|
|
|
var settings map[string]any
|
|
err = json.Unmarshal([]byte(inbound.Settings), &settings)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
clients := settings["clients"].([]any)
|
|
var newClients []any
|
|
for client_index := range clients {
|
|
c := clients[client_index].(map[string]any)
|
|
if c["email"] == clientEmail {
|
|
c["tgId"] = tgId
|
|
c["updated_at"] = time.Now().Unix() * 1000
|
|
newClients = append(newClients, any(c))
|
|
}
|
|
}
|
|
settings["clients"] = newClients
|
|
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
inbound.Settings = string(modifiedSettings)
|
|
needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
|
|
return needRestart, err
|
|
}
|
|
|
|
func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) {
|
|
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if inbound == nil {
|
|
return false, common.NewError("Inbound Not Found For Email:", clientEmail)
|
|
}
|
|
|
|
clients, err := inboundSvc.GetClients(inbound)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
isEnable := false
|
|
|
|
for _, client := range clients {
|
|
if client.Email == clientEmail {
|
|
isEnable = client.Enable
|
|
break
|
|
}
|
|
}
|
|
|
|
return isEnable, err
|
|
}
|
|
|
|
func (s *ClientService) ToggleClientEnableByEmail(inboundSvc *InboundService, clientEmail string) (bool, bool, error) {
|
|
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
if inbound == nil {
|
|
return false, false, common.NewError("Inbound Not Found For Email:", clientEmail)
|
|
}
|
|
|
|
oldClients, err := inboundSvc.GetClients(inbound)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
|
|
found := false
|
|
clientOldEnabled := false
|
|
|
|
for _, oldClient := range oldClients {
|
|
if oldClient.Email == clientEmail {
|
|
found = true
|
|
clientOldEnabled = oldClient.Enable
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return false, false, common.NewError("Client Not Found For Email:", clientEmail)
|
|
}
|
|
|
|
var settings map[string]any
|
|
err = json.Unmarshal([]byte(inbound.Settings), &settings)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
clients := settings["clients"].([]any)
|
|
var newClients []any
|
|
for client_index := range clients {
|
|
c := clients[client_index].(map[string]any)
|
|
if c["email"] == clientEmail {
|
|
c["enable"] = !clientOldEnabled
|
|
c["updated_at"] = time.Now().Unix() * 1000
|
|
newClients = append(newClients, any(c))
|
|
}
|
|
}
|
|
settings["clients"] = newClients
|
|
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
inbound.Settings = string(modifiedSettings)
|
|
|
|
needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
|
|
if err != nil {
|
|
return false, needRestart, err
|
|
}
|
|
|
|
return !clientOldEnabled, needRestart, nil
|
|
}
|
|
|
|
func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clientEmail string, enable bool) (bool, bool, error) {
|
|
current, err := s.CheckIsEnabledByEmail(inboundSvc, clientEmail)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
if current == enable {
|
|
return false, false, nil
|
|
}
|
|
newEnabled, needRestart, err := s.ToggleClientEnableByEmail(inboundSvc, clientEmail)
|
|
if err != nil {
|
|
return false, needRestart, err
|
|
}
|
|
return newEnabled == enable, needRestart, nil
|
|
}
|
|
|
|
// applyClientFieldByEmail loads the inbound currently hosting clientEmail,
|
|
// confirms the client exists, applies mutate to the matching client (plus a
|
|
// refreshed updated_at), and hands a single-client update payload to
|
|
// UpdateInboundClient. The rebuilt clients array intentionally contains only
|
|
// the matched client — that is the input contract UpdateInboundClient expects
|
|
// (clients[0] is the new data; clientEmail locates the row to replace). It
|
|
// backs the single-field by-email setters below.
|
|
func (s *ClientService) applyClientFieldByEmail(inboundSvc *InboundService, clientEmail string, mutate func(c map[string]any)) (bool, error) {
|
|
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if inbound == nil {
|
|
return false, common.NewError("Inbound Not Found For Email:", clientEmail)
|
|
}
|
|
|
|
oldClients, err := inboundSvc.GetClients(inbound)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
found := false
|
|
for _, oldClient := range oldClients {
|
|
if oldClient.Email == clientEmail {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return false, common.NewError("Client Not Found For Email:", clientEmail)
|
|
}
|
|
|
|
var settings map[string]any
|
|
err = json.Unmarshal([]byte(inbound.Settings), &settings)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
clients := settings["clients"].([]any)
|
|
var newClients []any
|
|
for client_index := range clients {
|
|
c := clients[client_index].(map[string]any)
|
|
if c["email"] == clientEmail {
|
|
mutate(c)
|
|
c["updated_at"] = time.Now().Unix() * 1000
|
|
newClients = append(newClients, any(c))
|
|
}
|
|
}
|
|
settings["clients"] = newClients
|
|
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
inbound.Settings = string(modifiedSettings)
|
|
return s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
|
|
}
|
|
|
|
func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {
|
|
return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
|
|
c["limitIp"] = count
|
|
})
|
|
}
|
|
|
|
func (s *ClientService) ResetClientExpiryTimeByEmail(inboundSvc *InboundService, clientEmail string, expiry_time int64) (bool, error) {
|
|
return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
|
|
c["expiryTime"] = expiry_time
|
|
})
|
|
}
|
|
|
|
func (s *ClientService) ResetClientTrafficLimitByEmail(inboundSvc *InboundService, clientEmail string, totalGB int) (bool, error) {
|
|
if totalGB < 0 {
|
|
return false, common.NewError("totalGB must be >= 0")
|
|
}
|
|
return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
|
|
c["totalGB"] = totalGB * 1024 * 1024 * 1024
|
|
})
|
|
}
|