refactor(clients): switch client API endpoints from id to email

All client-scoped routes now use the unique email as the path key
(get, update, del, attach, detach, links). Email is the stable,
protocol-independent identifier — UUIDs don't exist for trojan or
shadowsocks, and internal numeric ids leaked panel implementation
detail into the public API.

Removed the redundant /traffic/byId/:id endpoint (covered by
/traffic/:email) and collapsed /links/:id/:email into /links/:email,
which now returns links across every attached inbound for the client.

Frontend selection, bulk delete, and toggle state are now keyed by
email as well, dropping the id→email lookup workaround.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 16:31:38 +02:00
parent 79fb392a58
commit 1f4e2707a0
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
11 changed files with 162 additions and 172 deletions

View file

@ -381,10 +381,10 @@ export const sections = [
},
{
method: 'GET',
path: '/panel/api/clients/get/:id',
summary: 'Fetch one client by its numeric id, including the inbound IDs it is attached to.',
path: '/panel/api/clients/get/:email',
summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id from the clients table.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
],
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
@ -402,30 +402,30 @@ export const sections = [
},
{
method: 'POST',
path: '/panel/api/clients/update/:id',
summary: 'Update an existing client. Changes propagate to every attached inbound. Body is the JSON client payload.',
path: '/panel/api/clients/update/:email',
summary: 'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Current client email (unique identifier).' },
],
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "enable": true\n}',
response: '{\n "success": true,\n "msg": "Client updated"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/del/:id',
summary: 'Delete a client. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
path: '/panel/api/clients/del/:email',
summary: 'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
{ name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' },
],
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/:id/attach',
path: '/panel/api/clients/:email/attach',
summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' },
],
body: '{\n "inboundIds": [7, 9]\n}',
@ -433,10 +433,10 @@ export const sections = [
},
{
method: 'POST',
path: '/panel/api/clients/:id/detach',
path: '/panel/api/clients/:email/detach',
summary: 'Detach a client from one or more inbounds without deleting the client.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' },
],
body: '{\n "inboundIds": [5]\n}',
@ -508,15 +508,6 @@ export const sections = [
],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
},
{
method: 'GET',
path: '/panel/api/clients/traffic/byId/:id',
summary: 'Traffic counters for a client identified by its UUID/password.',
params: [
{ name: 'id', in: 'path', type: 'string', desc: 'Client UUID / password.' },
],
response: '{\n "success": true,\n "obj": [\n {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/clients/subLinks/:subId',
@ -530,12 +521,11 @@ export const sections = [
},
{
method: 'GET',
path: '/panel/api/clients/links/:id/:email',
path: '/panel/api/clients/links/:email',
summary:
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
"Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
],
response:
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',

View file

@ -219,7 +219,7 @@ async function onSubmit() {
const toDetach = [...original].filter((id) => !next.has(id));
msg = await props.save(clientPayload, {
isEdit: true,
id: props.client.id,
email: props.client.email,
attach: toAttach,
detach: toDetach,
});

View file

@ -62,17 +62,17 @@ useWebSocket({
invalidate: applyInvalidate,
});
const togglingId = ref(null);
const togglingEmail = ref(null);
async function onToggleEnable(row, next) {
togglingId.value = row.id;
togglingEmail.value = row.email;
try {
const msg = await setEnable(row, next);
if (!msg?.success) {
message.error(msg?.msg || t('somethingWentWrong'));
}
} finally {
togglingId.value = null;
togglingEmail.value = null;
}
}
@ -99,19 +99,19 @@ const rowSelection = computed(() => ({
onChange: (keys) => { selectedRowKeys.value = keys; },
}));
function toggleSelect(id, checked) {
function toggleSelect(email, checked) {
const cur = new Set(selectedRowKeys.value);
if (checked) cur.add(id);
else cur.delete(id);
if (checked) cur.add(email);
else cur.delete(email);
selectedRowKeys.value = Array.from(cur);
}
function isSelected(id) {
return selectedRowKeys.value.includes(id);
function isSelected(email) {
return selectedRowKeys.value.includes(email);
}
function selectAll(checked) {
selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.id) : [];
selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.email) : [];
}
const allSelected = computed(
@ -127,11 +127,11 @@ function onBulkAdd() {
}
function onBulkDelete() {
const ids = [...selectedRowKeys.value];
if (ids.length === 0) return;
const emails = [...selectedRowKeys.value];
if (emails.length === 0) return;
Modal.confirm({
title: t('pages.clients.bulkDeleteConfirmTitle', { count: ids.length })
|| `Delete ${ids.length} clients?`,
title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length })
|| `Delete ${emails.length} clients?`,
content: t('pages.clients.bulkDeleteConfirmContent')
|| 'Each client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.',
okText: t('delete'),
@ -140,8 +140,8 @@ function onBulkDelete() {
onOk: async () => {
let ok = 0;
let failed = 0;
for (const id of ids) {
const msg = await remove(id);
for (const email of emails) {
const msg = await remove(email);
if (msg?.success) ok++;
else failed++;
}
@ -318,7 +318,7 @@ function onDelete(row) {
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(row.id);
const msg = await remove(row.email);
if (msg?.success) message.success(t('pages.clients.toasts.deleted') || 'Client deleted');
},
});
@ -370,15 +370,14 @@ async function onSave(payload, meta) {
if (!meta?.isEdit) {
return create(payload);
}
const id = meta.id;
const updateMsg = await update(id, payload);
const updateMsg = await update(meta.email, payload);
if (!updateMsg?.success) return updateMsg;
if (Array.isArray(meta.attach) && meta.attach.length > 0) {
const r = await attach(id, meta.attach);
const r = await attach(meta.email, meta.attach);
if (!r?.success) return r;
}
if (Array.isArray(meta.detach) && meta.detach.length > 0) {
const r = await detach(id, meta.detach);
const r = await detach(meta.email, meta.detach);
if (!r?.success) return r;
}
return updateMsg;
@ -595,7 +594,7 @@ const columns = computed(() => [
</a-select>
</div>
<a-table v-if="!isMobile" :columns="columns" :data-source="filteredClients" :loading="loading" row-key="id"
<a-table v-if="!isMobile" :columns="columns" :data-source="filteredClients" :loading="loading" row-key="email"
:row-selection="rowSelection"
:pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10', '20', '50', '100'] }"
size="small">
@ -640,7 +639,7 @@ const columns = computed(() => [
</a-tooltip>
</template>
<template v-else-if="column.key === 'enable'">
<a-switch :checked="record.enable" size="small" :loading="togglingId === record.id"
<a-switch :checked="record.enable" size="small" :loading="togglingEmail === record.email"
@change="(next) => onToggleEnable(record, next)" />
</template>
<template v-else-if="column.key === 'actions'">
@ -699,11 +698,11 @@ const columns = computed(() => [
<div>{{ t('pages.clients.empty') || 'No clients yet.' }}</div>
</div>
<div v-for="row in filteredClients" :key="row.id" class="client-card"
:class="{ 'is-selected': isSelected(row.id) }">
<div v-for="row in filteredClients" :key="row.email" class="client-card"
:class="{ 'is-selected': isSelected(row.email) }">
<div class="card-head">
<a-checkbox :checked="isSelected(row.id)"
@change="(e) => toggleSelect(row.id, e.target.checked)" />
<a-checkbox :checked="isSelected(row.email)"
@change="(e) => toggleSelect(row.email, e.target.checked)" />
<a-badge :color="bucketTagColor(clientBucket(row))" />
<span class="tag-name">{{ row.email }}</span>
<a-tag v-if="clientBucket(row) === 'depleted'" color="red" class="status-tag">
@ -716,7 +715,7 @@ const columns = computed(() => [
<a-tooltip :title="t('pages.clients.moreInformation') || 'Info'">
<InfoCircleOutlined class="row-action-trigger" @click="onShowInfo(row)" />
</a-tooltip>
<a-switch :checked="row.enable" size="small" :loading="togglingId === row.id"
<a-switch :checked="row.enable" size="small" :loading="togglingEmail === row.email"
@change="(next) => onToggleEnable(row, next)" />
<a-dropdown :trigger="['click']" placement="bottomRight">
<MoreOutlined class="row-action-trigger" @click.prevent />

View file

@ -54,29 +54,37 @@ export function useClients() {
return msg;
}
async function update(id, client) {
const msg = await HttpUtil.post(`/panel/api/clients/update/${id}`, client, JSON_HEADERS);
async function update(email, client) {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function remove(id, keepTraffic = false) {
async function remove(email, keepTraffic = false) {
if (!email) return null;
const encoded = encodeURIComponent(email);
const url = keepTraffic
? `/panel/api/clients/del/${id}?keepTraffic=1`
: `/panel/api/clients/del/${id}`;
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
: `/panel/api/clients/del/${encoded}`;
const msg = await HttpUtil.post(url);
if (msg?.success) await refresh();
return msg;
}
async function attach(id, inboundIds) {
const msg = await HttpUtil.post(`/panel/api/clients/${id}/attach`, { inboundIds }, JSON_HEADERS);
async function attach(email, inboundIds) {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
async function detach(id, inboundIds) {
const msg = await HttpUtil.post(`/panel/api/clients/${id}/detach`, { inboundIds }, JSON_HEADERS);
async function detach(email, inboundIds) {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS);
if (msg?.success) await refresh();
return msg;
}
@ -102,7 +110,7 @@ export function useClients() {
}
async function setEnable(client, enable) {
if (!client?.id) return null;
if (!client?.email) return null;
const payload = {
email: client.email,
subId: client.subId,
@ -115,7 +123,7 @@ export function useClients() {
comment: client.comment || '',
enable: !!enable,
};
return update(client.id, payload);
return update(client.email, payload);
}
function applyTrafficEvent(payload) {

View file

@ -130,7 +130,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
spaPages := map[string]bool{
"/": true, "/panel/": true, "/panel/inbounds": true,
"/panel/clients": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/xray": true, "/panel/api-docs": true,
}
if spaPages[r.Path] {

View file

@ -3,7 +3,6 @@ package controller
import (
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
@ -26,12 +25,12 @@ func NewClientController(g *gin.RouterGroup) *ClientController {
func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/get/:id", a.get)
g.GET("/get/:email", a.get)
g.POST("/add", a.create)
g.POST("/update/:id", a.update)
g.POST("/del/:id", a.delete)
g.POST("/:id/attach", a.attach)
g.POST("/:id/detach", a.detach)
g.POST("/update/:email", a.update)
g.POST("/del/:email", a.delete)
g.POST("/:email/attach", a.attach)
g.POST("/:email/detach", a.detach)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/delDepleted", a.delDepleted)
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
@ -41,9 +40,8 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/onlines", a.onlines)
g.POST("/lastOnline", a.lastOnline)
g.GET("/traffic/:email", a.getTrafficByEmail)
g.GET("/traffic/byId/:id", a.getTrafficsByClientID)
g.GET("/subLinks/:subId", a.getSubLinks)
g.GET("/links/:id/:email", a.getClientLinks)
g.GET("/links/:email", a.getClientLinks)
}
func (a *ClientController) list(c *gin.Context) {
@ -56,17 +54,13 @@ func (a *ClientController) list(c *gin.Context) {
}
func (a *ClientController) get(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
email := c.Param("email")
rec, err := a.clientService.GetRecordByEmail(nil, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
rec, err := a.clientService.GetByID(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
inboundIds, err := a.clientService.GetInboundIdsForRecord(id)
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
@ -92,17 +86,13 @@ func (a *ClientController) create(c *gin.Context) {
}
func (a *ClientController) update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
email := c.Param("email")
var updated model.Client
if err := c.ShouldBindJSON(&updated); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Update(&a.inboundService, id, updated)
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
@ -114,13 +104,9 @@ func (a *ClientController) update(c *gin.Context) {
}
func (a *ClientController) delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
email := c.Param("email")
keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.Delete(&a.inboundService, id, keepTraffic)
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
@ -136,17 +122,13 @@ type attachDetachBody struct {
}
func (a *ClientController) attach(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
email := c.Param("email")
var body attachDetachBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Attach(&a.inboundService, id, body.InboundIds)
needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
@ -277,16 +259,6 @@ func (a *ClientController) getTrafficByEmail(c *gin.Context) {
jsonObj(c, traffic, nil)
}
func (a *ClientController) getTrafficsByClientID(c *gin.Context) {
id := c.Param("id")
traffics, err := a.inboundService.GetClientTrafficByID(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, traffics, nil)
}
func (a *ClientController) getSubLinks(c *gin.Context) {
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
if err != nil {
@ -297,12 +269,7 @@ func (a *ClientController) getSubLinks(c *gin.Context) {
}
func (a *ClientController) getClientLinks(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
@ -311,17 +278,13 @@ func (a *ClientController) getClientLinks(c *gin.Context) {
}
func (a *ClientController) detach(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
email := c.Param("email")
var body attachDetachBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Detach(&a.inboundService, id, body.InboundIds)
needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return

View file

@ -7,8 +7,8 @@ import (
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
tests := []struct {
name string
input string
name string
input string
// wantCertFile / wantKeyFile: expected presence after sanitize
wantCertFile bool
wantKeyFile bool
@ -55,7 +55,7 @@ func TestSanitizeStreamSettingsForRemote(t *testing.T) {
wantKeyFile: false,
},
{
name: "empty stream settings",
name: "empty stream settings",
input: "",
// empty input returns empty, nothing to check
},

View file

@ -496,6 +496,50 @@ func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int,
return s.Detach(inboundSvc, rec.Id, []int{inboundId})
}
func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Attach(inboundSvc, rec.Id, inboundIds)
}
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Detach(inboundSvc, rec.Id, inboundIds)
}
func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Delete(inboundSvc, rec.Id, keepTraffic)
}
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Update(inboundSvc, rec.Id, updated)
}
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
@ -571,11 +615,11 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
return submitTrafficWrite(func() error {
return s.resetAllClientTrafficsLocked(inboundSvc, id)
return s.resetAllClientTrafficsLocked(id)
})
}
func (s *ClientService) resetAllClientTrafficsLocked(inboundSvc *InboundService, id int) error {
func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
db := database.GetDB()
now := time.Now().Unix() * 1000

View file

@ -251,7 +251,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
model.Hysteria: true,
model.Hysteria2: true,
}
if !protocolsWithStream[inbound.Protocol] {
inbound.StreamSettings = ""
}
@ -264,7 +264,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
exist, err := s.checkPortConflict(inbound, 0)
if err != nil {
return inbound, false, err
@ -527,7 +527,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
exist, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil {
return inbound, false, err
@ -1015,7 +1015,6 @@ func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID
return result, needRestart, nil
}
const resetGracePeriodMs int64 = 30000
// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
@ -1689,8 +1688,8 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
}
type target struct {
InboundID int `gorm:"column:inbound_id"`
NodeID *int `gorm:"column:node_id"`
InboundID int `gorm:"column:inbound_id"`
NodeID *int `gorm:"column:node_id"`
Tag string
Email string
}
@ -1982,7 +1981,6 @@ func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraff
return nil, nil, common.NewError("Client Not Found In Inbound For Email:", clientEmail)
}
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
return submitTrafficWrite(func() error {
db := database.GetDB()
@ -2468,33 +2466,6 @@ func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64,
})
}
func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, error) {
db := database.GetDB()
var traffics []xray.ClientTraffic
err := db.Model(xray.ClientTraffic{}).Where(`email IN(
SELECT JSON_EXTRACT(client.value, '$.email') as email
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
WHERE
JSON_EXTRACT(client.value, '$.id') in (?)
)`, id).Find(&traffics).Error
if err != nil {
logger.Debug(err)
return nil, err
}
// Reconcile enable flag with client settings per email to avoid stale DB value
for i := range traffics {
if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
traffics[i].Enable = client.Enable
traffics[i].UUID = client.ID
traffics[i].SubId = client.SubID
}
}
return traffics, err
}
func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.ClientTraffic, err error) {
db := database.GetDB()
inbound := &model.Inbound{}
@ -2882,13 +2853,28 @@ func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) {
}
return registeredSubLinkProvider.SubLinksForSubId(host, subId)
}
func (s *InboundService) GetClientLinks(host string, id int, email string) ([]string, error) {
inbound, err := s.GetInbound(id)
if err != nil {
return nil, err
func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) {
if email == "" {
return nil, common.NewError("client email is required")
}
if registeredSubLinkProvider == nil {
return nil, common.NewError("sub link provider not registered")
}
return registeredSubLinkProvider.LinksForClient(host, inbound, email), nil
rec, err := s.clientService.GetRecordByEmail(nil, email)
if err != nil {
return nil, err
}
inboundIds, err := s.clientService.GetInboundIdsForRecord(rec.Id)
if err != nil {
return nil, err
}
var links []string
for _, ibId := range inboundIds {
inbound, getErr := s.GetInbound(ibId)
if getErr != nil {
return nil, getErr
}
links = append(links, registeredSubLinkProvider.LinksForClient(host, inbound, email)...)
}
return links, nil
}

View file

@ -95,7 +95,7 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
now := time.Now().UnixMilli()
type trafficRow struct {
InboundID int `gorm:"column:inbound_id"`
InboundID int `gorm:"column:inbound_id"`
Email string
Enable bool
Total int64

View file

@ -71,12 +71,12 @@ type Status struct {
ErrorMsg string `json:"errorMsg"`
Version string `json:"version"`
} `json:"xray"`
PanelVersion string `json:"panelVersion"`
Uptime uint64 `json:"uptime"`
Loads []float64 `json:"loads"`
TcpCount int `json:"tcpCount"`
UdpCount int `json:"udpCount"`
NetIO struct {
PanelVersion string `json:"panelVersion"`
Uptime uint64 `json:"uptime"`
Loads []float64 `json:"loads"`
TcpCount int `json:"tcpCount"`
UdpCount int `json:"udpCount"`
NetIO struct {
Up uint64 `json:"up"`
Down uint64 `json:"down"`
} `json:"netIO"`