Files
3x-ui/frontend/src/schemas/client.ts
T
n0ctal 5c7ca5b579 feat(clients): give each client its own traffic reset cycle (#6240)
* feat(clients): give each client its own traffic reset cycle

Traffic reset is configured on the inbound, so every client sharing an
inbound resets together. An operator running a monthly 1000GB plan and a
weekly 200GB plan side by side has to press Reset Traffic by hand.

Clients now carry the same trafficReset / trafficResetDay pair the
inbound already has, with the same vocabulary and the same monthly
due-day rule, and PeriodicTrafficResetJob makes a second pass over the
clients whose own cycle matches the period it is running for. A client
that leaves the field at never behaves exactly as before: only its
inbound's schedule can reset it.

The fields live on ClientRecord as well as in the inbound settings JSON,
so an ordinary edit does not write the cycle back as empty, and an
unknown period is rejected rather than coerced, since a coerced value
would read as configured while no job would ever select the client.

Cron expressions and a custom post-reset quota from the issue are left
out: both are separate decisions, and neither has an inbound-level
counterpart to stay consistent with.

* fix(clients): make the per-client reset cycle editable and safe to run

Review found three things wrong with the first cut, one of them mine and
worse than the bug it replaced.

The cycle could only be set at creation. ClientService.Update writes the
columns directly only for a client with no inbounds; the normal path goes
through SyncInbound and applyClientRecordMerge, which this change had not
extended, so an edit updated the settings JSON while the clients column
kept the old value and the job kept applying the old cycle. The earlier
test passed because it asserted the value survived an unrelated edit,
which it did precisely because nothing ever wrote it. Replaced with a
test that changes the cycle and switches it off again.

Avoiding the re-enable that ResetTrafficByEmail performs was wrong.
Depletion disables clients.enable and the settings JSON as well as
client_traffics.enable, so lifting only the quota gate left a depleted
client out of the generated config with zeroed counters, which no longer
match the depleted predicate: locked out permanently. The rule is now
about cause, not state — a client the quota switched off is restored, one
disabled below its quota was switched off by hand and is skipped.

The bulk path also bypassed node propagation and the MTProto sidecar
quota that ResetTrafficByEmail handles, so it silently did nothing on
node-backed inbounds. Dropped in favour of the integrated path, whose
needRestart is now collected and turned into a single SetToNeedRestart.

Also adds the AutoMigrate NULL backfill, guards the merge so a stale node
snapshot cannot erase a configured cycle, validates the bulk-create and
import paths, normalizes the day the way the inbound path does, marks the
fields omitempty so existing clients match the published contract, and
shares one TRAFFIC_RESETS tuple between the three forms.

* fix(clients): validate renew fields on the bulk and import paths too

BulkCreate and ImportClients insert client records without going through
Create, so the resetDay/resetMax checks added with the calendar renewal
(#6239) and the renew cap (#6238) never ran there. An API caller could
store resetDay 45 or a negative resetMax, values the renewal query then
mishandles silently. Mirror Create's validation on both batch paths, next
to the trafficReset check they already carry.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-18 13:08:44 +02:00

281 lines
9.9 KiB
TypeScript

import { z } from 'zod';
const nullableStringArray = z.array(z.string()).nullable().transform((v) => v ?? []);
const nullableNumberArray = z.array(z.number()).nullable().transform((v) => v ?? []);
export const ClientTrafficSchema = z.object({
up: z.number().optional(),
down: z.number().optional(),
total: z.number().optional(),
expiryTime: z.number().optional(),
enable: z.boolean().optional(),
lastOnline: z.number().optional(),
lastSubFetch: z.number().optional(),
resetMax: z.number().optional(),
resetCount: z.number().optional(),
});
export const ClientRecordSchema = z.object({
id: z.number().optional(),
email: z.string(),
subId: z.string().optional(),
uuid: z.string().optional(),
password: z.string().optional(),
auth: z.string().optional(),
flow: z.string().optional(),
security: z.string().optional(),
totalGB: z.number().optional(),
expiryTime: z.number().optional(),
limitIp: z.number().optional(),
limitHwid: z.number().optional(),
tgId: z.union([z.number(), z.string()]).optional(),
group: z.string().optional(),
comment: z.string().optional(),
enable: z.boolean().optional(),
reset: z.number().optional(),
resetDay: z.number().optional(),
resetMax: z.number().optional(),
trafficReset: z.string().optional(),
trafficResetDay: z.number().optional(),
inboundIds: nullableNumberArray.optional(),
traffic: ClientTrafficSchema.nullable().optional(),
reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(),
privateKey: z.string().optional(),
publicKey: z.string().optional(),
allowedIPs: z.string().optional(),
preSharedKey: z.string().optional(),
keepAlive: z.number().optional(),
secret: z.string().optional(),
adTag: z.string().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
}).loose();
export const InboundOptionSchema = z.object({
id: z.number(),
remark: z.string().optional(),
tag: z.string().optional(),
protocol: z.string().optional(),
port: z.number().optional(),
tlsFlowCapable: z.boolean().optional(),
ssMethod: z.string().optional(),
wgPublicKey: z.string().optional(),
wgMtu: z.number().optional(),
wgDns: z.string().optional(),
mtprotoDomain: z.string().optional(),
// Hosting node id; absent/null for this panel's own inbounds (#4997).
nodeId: z.number().nullable().optional(),
// Share-host resolution inputs, mirroring the backend resolveInboundAddress so
// the clients page picks the same WireGuard endpoint host as the subscription:
// the hosting node address, the inbound listen, and its share-address strategy.
nodeAddress: z.string().optional(),
listen: z.string().optional(),
shareAddr: z.string().optional(),
shareAddrStrategy: z.string().optional(),
}).loose();
export const InboundOptionsSchema = z.array(InboundOptionSchema);
// The *Count fields are exact; the email arrays stop at the server's cap and
// only feed the hover popovers, so never derive a counter from their length.
export const ClientsSummarySchema = z.object({
total: z.number(),
active: z.number(),
onlineCount: z.number().optional().default(0),
depletedCount: z.number().optional().default(0),
expiringCount: z.number().optional().default(0),
deactiveCount: z.number().optional().default(0),
online: nullableStringArray,
depleted: nullableStringArray,
expiring: nullableStringArray,
deactive: nullableStringArray,
});
const nullableClientArray = z.array(ClientRecordSchema).nullable().transform((v) => v ?? []);
export const ClientPageResponseSchema = z.object({
items: nullableClientArray,
total: z.number(),
filtered: z.number(),
page: z.number(),
pageSize: z.number(),
summary: ClientsSummarySchema.nullable().optional(),
groups: nullableStringArray.optional(),
});
// A per-client external link surfaced in the client's subscription:
// kind=link is a single share link, kind=subscription is a remote sub URL.
export const ExternalLinkSchema = z.object({
kind: z.enum(['link', 'subscription']).default('link'),
value: z.string(),
remark: z.string().optional().default(''),
}).loose();
export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
export const ClientHydrateSchema = z.object({
client: ClientRecordSchema,
inboundIds: nullableNumberArray,
externalLinks: ExternalLinkListSchema.optional(),
});
export const BulkAdjustResultSchema = z.object({
adjusted: z.number(),
skipped: z
.array(z.object({ email: z.string(), reason: z.string() }))
.optional(),
});
export const BulkDeleteResultSchema = z.object({
deleted: z.number(),
skipped: z
.array(z.object({ email: z.string(), reason: z.string() }))
.optional(),
});
export const BulkSetEnableResultSchema = z.object({
changed: z.number(),
skipped: z
.array(z.object({ email: z.string(), reason: z.string() }))
.optional(),
});
export const BulkCreateResultSchema = z.object({
created: z.number(),
skipped: z
.array(z.object({ email: z.string(), reason: z.string() }))
.optional(),
});
export const DelDepletedResultSchema = z.object({
deleted: z.number().optional(),
});
export const BulkAttachResultSchema = z.object({
attached: z.array(z.string()).nullable().transform((v) => v ?? []),
skipped: z.array(z.string()).nullable().transform((v) => v ?? []),
errors: z.array(z.string()).nullable().transform((v) => v ?? []),
});
export const BulkDetachResultSchema = z.object({
detached: z.array(z.string()).nullable().transform((v) => v ?? []),
skipped: z.array(z.string()).nullable().transform((v) => v ?? []),
errors: z.array(z.string()).nullable().transform((v) => v ?? []),
});
export const OnlinesSchema = nullableStringArray;
export const OnlineByNodeSchema = z
.record(z.string(), nullableStringArray)
.nullable()
.transform((v) => v ?? {});
export const ActiveInboundsByNodeSchema = z
.record(z.string(), nullableStringArray)
.nullable()
.transform((v) => v ?? {});
export const GroupSummarySchema = z.object({
name: z.string(),
clientCount: z.number(),
trafficUsed: z.number().nullable().transform((v) => v ?? 0),
up: z.number().nullable().transform((v) => v ?? 0),
down: z.number().nullable().transform((v) => v ?? 0),
});
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
export function hasForbiddenClientChars(value: string): boolean {
if (value.includes('/') || value.includes('\\') || value.includes(' ')) return true;
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code < 0x20 || code === 0x7f) return true;
}
return false;
}
export const ClientFormSchema = z.object({
email: z
.string()
.trim()
.min(1, 'pages.clients.email')
.refine((v) => !hasForbiddenClientChars(v), 'pages.clients.emailInvalidChars'),
subId: z.string().refine((v) => !hasForbiddenClientChars(v), 'pages.clients.subIdInvalidChars'),
uuid: z.string(),
password: z.string(),
auth: z.string(),
flow: z.string(),
security: z.string(),
reverseTag: z.string(),
totalGB: z.number().min(0),
delayedStart: z.boolean(),
delayedDays: z.number().int().min(0),
reset: z.number().int().min(0),
resetDay: z.number().int().min(0).max(31),
resetMax: z.number().int().min(0),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
trafficResetDay: z.number().int().min(1).max(31),
limitIp: z.number().int().min(0),
limitHwid: z.number().int().min(0),
tgId: z.number().int().min(0),
group: z.string(),
comment: z.string(),
enable: z.boolean(),
inboundIds: z.array(z.number()),
});
export const ClientCreateFormSchema = ClientFormSchema.extend({
inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
});
export const ClientBulkAdjustFormSchema = z
.object({
addDays: z.number().int(),
addGB: z.number(),
flow: z.string().optional().default(''),
})
.refine((v) => v.addDays !== 0 || v.addGB !== 0 || v.flow !== '', {
message: 'pages.clients.bulkAdjustNothing',
});
export const ClientBulkAddFormSchema = z.object({
emailMethod: z.number().int().min(0).max(4),
firstNum: z.number().int().min(1),
lastNum: z.number().int().min(1),
emailPrefix: z.string(),
emailPostfix: z.string(),
quantity: z.number().int().min(1).max(1000),
subId: z.string(),
group: z.string(),
comment: z.string(),
flow: z.string(),
limitIp: z.number().int().min(0),
limitHwid: z.number().int().min(0),
totalGB: z.number().min(0),
expiryTime: z.number(),
reset: z.number().int().min(0),
resetDay: z.number().int().min(0).max(31),
resetMax: z.number().int().min(0),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']).optional(),
trafficResetDay: z.number().int().min(1).max(31).optional(),
inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
});
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
export type InboundOption = z.infer<typeof InboundOptionSchema>;
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
export type BulkDeleteResult = z.infer<typeof BulkDeleteResultSchema>;
export type BulkSetEnableResult = z.infer<typeof BulkSetEnableResultSchema>;
export type BulkCreateResult = z.infer<typeof BulkCreateResultSchema>;
export type BulkAttachResult = z.infer<typeof BulkAttachResultSchema>;
export type BulkDetachResult = z.infer<typeof BulkDetachResultSchema>;
export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
export type ClientFormValues = z.infer<typeof ClientFormSchema>;
export type GroupSummary = z.infer<typeof GroupSummarySchema>;