diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 37912f0e6..6b945db65 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -81,6 +81,7 @@ export function createDefaultVmessClient(seed: VmessClientSeed = {}): VmessClien return { id: seed.id ?? RandomUtil.randomUUID(), security: seed.security ?? 'auto', + alterId: 0, ...clientBase(seed), }; } diff --git a/frontend/src/schemas/protocols/inbound/hysteria.ts b/frontend/src/schemas/protocols/inbound/hysteria.ts index c6e9fbc92..a9f14eed7 100644 --- a/frontend/src/schemas/protocols/inbound/hysteria.ts +++ b/frontend/src/schemas/protocols/inbound/hysteria.ts @@ -10,7 +10,7 @@ export const HysteriaClientSchema = z.object({ totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/schemas/protocols/inbound/shadowsocks.ts b/frontend/src/schemas/protocols/inbound/shadowsocks.ts index 9c18c22f4..9fd0e52a0 100644 --- a/frontend/src/schemas/protocols/inbound/shadowsocks.ts +++ b/frontend/src/schemas/protocols/inbound/shadowsocks.ts @@ -17,7 +17,7 @@ export const ShadowsocksClientSchema = z.object({ totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/schemas/protocols/inbound/trojan.ts b/frontend/src/schemas/protocols/inbound/trojan.ts index 587da3ea1..d77b26660 100644 --- a/frontend/src/schemas/protocols/inbound/trojan.ts +++ b/frontend/src/schemas/protocols/inbound/trojan.ts @@ -16,7 +16,7 @@ export const TrojanClientSchema = z.object({ totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/schemas/protocols/inbound/vless.ts b/frontend/src/schemas/protocols/inbound/vless.ts index 381cd7d59..666bbafd4 100644 --- a/frontend/src/schemas/protocols/inbound/vless.ts +++ b/frontend/src/schemas/protocols/inbound/vless.ts @@ -12,14 +12,14 @@ export const VlessFallbackSchema = z.object({ export type VlessFallback = z.infer; export const VlessClientSchema = z.object({ - id: z.uuid(), + id: z.string().min(1), email: z.string().min(1), flow: FlowSchema.default(''), limitIp: z.number().int().min(0).default(0), totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/schemas/protocols/inbound/vmess.ts b/frontend/src/schemas/protocols/inbound/vmess.ts index b16aded15..7be773d43 100644 --- a/frontend/src/schemas/protocols/inbound/vmess.ts +++ b/frontend/src/schemas/protocols/inbound/vmess.ts @@ -3,14 +3,15 @@ import { z } from 'zod'; import { VmessSecuritySchema } from '../shared/vmess'; export const VmessClientSchema = z.object({ - id: z.uuid(), + id: z.string().min(1), security: VmessSecuritySchema.default('auto'), + alterId: z.number().int().min(0).default(0), email: z.string().min(1), limitIp: z.number().int().min(0).default(0), totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap b/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap index 263572e29..b923cd4a6 100644 --- a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap @@ -129,6 +129,7 @@ exports[`createDefaultVlessClient > produces a Zod-valid client 1`] = ` exports[`createDefaultVmessClient > produces a Zod-valid client 1`] = ` { + "alterId": 0, "comment": "", "email": "fixture@example.test", "enable": true, diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index f162cadce..87f3b1064 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -505,6 +505,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] = "settings": { "clients": [ { + "alterId": 0, "comment": "", "email": "carol@example.test", "enable": true, diff --git a/frontend/src/test/__snapshots__/protocols.test.ts.snap b/frontend/src/test/__snapshots__/protocols.test.ts.snap index 40e980241..4c5d0b4a6 100644 --- a/frontend/src/test/__snapshots__/protocols.test.ts.snap +++ b/frontend/src/test/__snapshots__/protocols.test.ts.snap @@ -185,6 +185,7 @@ exports[`InboundSettingsSchema fixtures > parses vmess-basic byte-stably 1`] = ` "settings": { "clients": [ { + "alterId": 0, "comment": "primary tester", "email": "bob@example.test", "enable": true, diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 8545a23f3..dd5d12a4d 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -576,49 +576,42 @@ export class ClipboardManager { } static _legacyCopy(text: string): boolean { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.setAttribute('aria-hidden', 'true'); - textarea.style.position = 'absolute'; - textarea.style.left = '-9999px'; - textarea.style.top = '0'; - textarea.style.opacity = '1'; + const span = document.createElement('span'); + span.textContent = text; + span.style.whiteSpace = 'pre'; + span.style.position = 'absolute'; + span.style.left = '-9999px'; + span.style.top = '0'; - const active = document.activeElement as HTMLElement | null; - const host = (active && active !== document.body && active.parentElement) - ? active.parentElement - : document.body; - host.appendChild(textarea); + document.body.appendChild(span); - const sel0 = document.getSelection(); - const prevSelection = sel0 && sel0.rangeCount ? sel0.getRangeAt(0) : null; + const selection = window.getSelection(); + if (!selection) { + document.body.removeChild(span); + return false; + } + + const prevSelection = selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + selection.removeAllRanges(); + const range = window.document.createRange(); + range.selectNodeContents(span); + selection.addRange(range); let ok = false; try { - textarea.focus({ preventScroll: true }); - textarea.select(); - textarea.setSelectionRange(0, text.length); - // Routed through a dynamic lookup so the @deprecated tag on - // Document.execCommand doesn't surface here. execCommand is the - // only copy path that works in insecure contexts (HTTP panels - // behind IP/localhost) — reached only after navigator.clipboard - // fails or is unavailable. const exec = (document as unknown as Record)['execCommand']; if (typeof exec === 'function') { ok = (exec as (cmd: string) => boolean).call(document, 'copy'); } } catch {} - host.removeChild(textarea); - if (active && typeof active.focus === 'function') { - try { active.focus({ preventScroll: true }); } catch {} - } + selection.removeAllRanges(); if (prevSelection) { - const sel = document.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(prevSelection); + selection.addRange(prevSelection); } + + document.body.removeChild(span); return ok; } } diff --git a/util/link/outbound.go b/util/link/outbound.go index 77c8ec1eb..4b11bf7c7 100644 --- a/util/link/outbound.go +++ b/util/link/outbound.go @@ -552,12 +552,13 @@ func buildStream(network, security string) map[string]any { default: stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}} } - if security == "tls" { + switch security { + case "tls": stream["tlsSettings"] = map[string]any{ "serverName": "", "alpn": []any{}, "fingerprint": "", "echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "", } - } else if security == "reality" { + case "reality": stream["realitySettings"] = map[string]any{ "publicKey": "", "fingerprint": "chrome", "serverName": "", "shortId": "", "spiderX": "", "mldsa65Verify": "", @@ -624,7 +625,8 @@ func applyTransport(stream map[string]any, p url.Values) { func applySecurity(stream map[string]any, p url.Values) { sec := stream["security"].(string) - if sec == "tls" { + switch sec { + case "tls": tls := stream["tlsSettings"].(map[string]any) tls["serverName"] = p.Get("sni") tls["fingerprint"] = p.Get("fp") @@ -633,7 +635,7 @@ func applySecurity(stream map[string]any, p url.Values) { } tls["echConfigList"] = p.Get("ech") tls["pinnedPeerCertSha256"] = p.Get("pcs") - } else if sec == "reality" { + case "reality": re := stream["realitySettings"].(map[string]any) re["serverName"] = p.Get("sni") re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome") diff --git a/util/link/outbound_test.go b/util/link/outbound_test.go index 95b2ca9ca..1dbadaf00 100644 --- a/util/link/outbound_test.go +++ b/util/link/outbound_test.go @@ -59,4 +59,4 @@ func TestSlugAndSuggest(t *testing.T) { if tag != "hk-sg-01" { t.Errorf("suggest tag got %q", tag) } -} \ No newline at end of file +} diff --git a/web/job/outbound_subscription_job.go b/web/job/outbound_subscription_job.go index 0510a8a7d..412813e64 100644 --- a/web/job/outbound_subscription_job.go +++ b/web/job/outbound_subscription_job.go @@ -45,4 +45,4 @@ func (j *OutboundSubscriptionJob) Run() { // view (new outbounds will be visible after the reload cycle). websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds) } -} \ No newline at end of file +} diff --git a/web/service/client.go b/web/service/client.go index f2114eaed..f0579f359 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -1493,13 +1493,27 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st if err != nil { return false, err } + + needRestart := false + if !rec.Enable { + updated := rec.ToClient() + updated.Enable = true + nr, uErr := s.Update(inboundSvc, rec.Id, *updated) + if uErr != nil { + logger.Warning("Failed to auto-enable client during traffic reset:", uErr) + } + if nr { + needRestart = true + } + } + if len(inboundIds) == 0 { if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil { return false, rErr } - return false, nil + return needRestart, nil } - needRestart := false + for _, ibId := range inboundIds { nr, rErr := inboundSvc.ResetClientTraffic(ibId, email) if rErr != nil { @@ -1809,6 +1823,15 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st return 0, nil } + for _, e := range cleanEmails { + rec, err := s.GetRecordByEmail(nil, e) + if err == nil && !rec.Enable { + updated := rec.ToClient() + updated.Enable = true + s.Update(inboundSvc, rec.Id, *updated) + } + } + affected := 0 err := submitTrafficWrite(func() error { db := database.GetDB() diff --git a/web/service/outbound_subscription.go b/web/service/outbound_subscription.go index d5b206e6d..2c16e4bfd 100644 --- a/web/service/outbound_subscription.go +++ b/web/service/outbound_subscription.go @@ -537,4 +537,4 @@ Consequences for balancers / routing: We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription outbounds are always injected at runtime in GetXrayConfig. -*/ \ No newline at end of file +*/