Files
3x-ui/internal/web/controller/client.go
NgaiYeanCoi 6a5b4fab6a feat(happ): generate Crypt5 subscription links locally (#6494)
* feat(clients): add stateless Happ link generator

Generate Happ provider links from the current effective subscription source without caching results. Reject unsafe provider responses and redact failure diagnostics.

* fix(clients): reject duplicate Happ provider fields

Parse Happ provider objects token by token so duplicate supported keys cannot be silently overwritten by encoding/json.

* feat(clients): expose on-demand Happ link API

Expose a no-store client endpoint backed by the Happ link generator and keep its generated OpenAPI contract synchronized.

* fix(openapi): exclude service interfaces from generated types

Keep dependency-injection interfaces out of the frontend API surface while preserving allowed response schemas.

* feat(clients): add stateless Happ QR presentation

Generate Happ links only for the active modal scope and retire late responses so Standard remains immediately available. Add focused component coverage and localized retry guidance across every locale.

* fix(clients): cover overlapping Happ generations

Prove the cancellation cleanup is required by resolving a retired request while its replacement remains pending. Also wait for Regenerate to leave loading state before exercising the existing action.

* fix(clients): harden Happ link handling

Validate generated responses before rendering and hide actions during unresolved requests. Strengthen route, redirect, timeout, and lint regression coverage with mutation-sensitive tests.

* fix(clients): gate Happ link generation behind operator opt-in

- add a fail-closed happLinkEnable setting
- enforce the gate before and after provider requests
- add locked Happ QR state with privacy disclosure and settings link
- cover backend, frontend, settings, and i18n regressions

* fix(frontend): guard oversized Happ QR codes

Keep valid long crypt5 links copyable while suppressing QR rendering and image actions above the encoder's UTF-8 byte limit. Add localized guidance and boundary coverage.

* fix(clients): log the sanitized transport error for Happ link failures

Every fail() call in HappService.Generate passed a string literal as the
detail, so the sanitizer written for provider errors only ever saw
constants, and an operator following the QR modal's "check Logs" hint
found nothing beyond reason=transport. Transport and body-read errors now
flow through sanitizeHappDetail, which also redacts cookie/session pairs.

Drop TestHappLinkEnableDefaultsOffWithoutPersistingRow: it pinned a getter
and its constant default, which the Generate gate test already drives.

* fix(frontend): size the Happ QR cap to level L and keep the QR modal mounted on close

HAPP_QR_MAX_BYTES was the level-M capacity (2331) while QrPanel encodes at
errorLevel "L", whose version-40 byte-mode capacity is 2953, so valid links
between 2332 and 2953 bytes lost their QR. The cap now matches the encoder
and a test renders the real QrPanel at the boundary.

Keying the modal content on `open` remounted it on every close, which cut
the Modal's exit transition and made the openSubId sync unreachable, so
`loading` never turned on for the subLinks fetch and a client without a
subscription link flashed noLinks on reopen. `open` leaves the key and the
sync block now also resets the Happ state.

* chore(clients): request Happ crypt5 links from api-v3

crypto.happ.su serves api-v2.php and api-v3.php side by side. Probed with
the same payloads, both take {"url"} over a JSON POST, answer
{"encrypted_link":"happ://crypt5/..."} of identical length with the same
crypt5 key marker, and fail the same way: 400 "No url provided.",
500 "Invalid URL format.", 405 on GET. Happ's own generator page is
branded "URL Encryption v3", so the panel follows it. The parser and the
link validator are unchanged.

* feat: add local generation of encrypted Happ links

- Implemented functionality to generate encrypted Happ links locally without network dependency.
- Added validation for URL length and format to ensure compliance with processing limits.
- Introduced new error handling for invalid URLs and control characters.
- Updated translations for various languages to reflect changes in Happ link generation.
- Created unit tests to validate the encryption process and ensure session keys and nonces are unique.

* fix(frontend): match the tuic memo deps to the non-optional subSettings

The Happ branch reads subSettings non-optionally in ClientQrModalContent
(happLinkEnable and the WireGuard/AmneziaWG publicHost memos), so React
Compiler infers subSettings.publicHost. The TUIC memo merged in from main
still listed subSettings?.publicHost, which fails oxlint's
preserve-manual-memoization rule and makes the compiler skip optimizing
the component. make verify stopped at lint-fe on the branch head.

* chore(happ): trim the pinned-key provenance comment to two lines

CLAUDE.md caps a comment block at two lines. The bare URL line repeated
the repository and file the next line already names, so it is folded
into that line (review LOW on happ_crypto.go).

---------

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

719 lines
21 KiB
Go

package controller
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
"github.com/gin-gonic/gin"
)
func notifyClientsChanged() {
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
}
func parseInboundIdsQuery(raw string) []int {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
ids = append(ids, id)
}
}
return ids
}
type ClientController struct {
clientService service.ClientService
inboundService service.InboundService
xrayService service.XrayService
settingService service.SettingService
happGenerator service.HappLinkGenerator
}
func NewClientController(g *gin.RouterGroup) *ClientController {
a := &ClientController{}
a.happGenerator = service.NewHappService(&a.clientService, &a.settingService)
a.initRouter(g)
return a
}
func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged)
g.GET("/get/:email", a.get)
g.GET("/get/tgId/:tgId", a.getByTgId)
g.GET("/traffic/:email", a.getTrafficByEmail)
g.GET("/subLinks/:subId", a.getSubLinks)
g.GET("/links/:email", a.getClientLinks)
g.POST("/happLink/:id", a.generateHappLink)
g.POST("/add", a.create)
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("/:email/externalLinks", a.setExternalLinks)
g.GET("/export", a.export)
g.POST("/import", a.importClients)
g.POST("/delOrphans", a.delOrphans)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/delDepleted", a.delDepleted)
g.POST("/bulkAdjust", a.bulkAdjust)
g.POST("/bulkEnable", a.bulkEnable)
g.POST("/bulkDisable", a.bulkDisable)
g.POST("/bulkDel", a.bulkDelete)
g.POST("/bulkCreate", a.bulkCreate)
g.POST("/bulkAttach", a.bulkAttach)
g.POST("/bulkDetach", a.bulkDetach)
g.POST("/bulkResetTraffic", a.bulkResetTraffic)
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
g.POST("/ips/:email", a.getIps)
g.POST("/clearIps/:email", a.clearIps)
g.POST("/hwids/:email", a.getHwids)
g.DELETE("/hwids/:email", a.clearHwids)
g.DELETE("/hwids/:email/:id", a.deleteHwid)
g.POST("/onlines", a.onlines)
g.POST("/onlinesByGuid", a.onlinesByGuid)
g.POST("/clientIpsByGuid", a.clientIpsByGuid)
g.POST("/activeInbounds", a.activeInbounds)
g.POST("/lastOnline", a.lastOnline)
}
func (a *ClientController) list(c *gin.Context) {
rows, err := a.clientService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *ClientController) listPaged(c *gin.Context) {
var params service.ClientPageParams
if err := c.ShouldBindQuery(&params); err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
resp, err := a.clientService.ListPaged(&a.inboundService, &a.settingService, params)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, resp, nil)
}
func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, error) {
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
if err != nil {
return nil, err
}
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
if err != nil {
return nil, err
}
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
if err != nil {
return nil, err
}
rec.Flow = flow
var usedTraffic int64
if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil {
usedTraffic = t.Up + t.Down
}
tunnelAllowedIPs, err := a.clientService.TunnelAllowedIPsByInbound(&a.inboundService, rec.Email, inboundIds)
if err != nil {
return nil, err
}
return gin.H{
"client": rec,
"inboundIds": inboundIds,
"externalLinks": externalLinks,
"usedTraffic": usedTraffic,
"tunnelAllowedIPs": tunnelAllowedIPs,
}, nil
}
func (a *ClientController) get(c *gin.Context) {
email := c.Param("email")
rec, err := a.clientService.GetRecordByEmail(nil, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
payload, err := a.buildClientPayload(rec)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, payload, nil)
}
func (a *ClientController) getByTgId(c *gin.Context) {
tgIdStr := c.Param("tgId")
tgId, err := strconv.ParseInt(tgIdStr, 10, 64)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
records, err := a.clientService.GetRecordsByTgID(tgId)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
results := make([]gin.H, 0, len(records))
for _, rec := range records {
payload, err := a.buildClientPayload(rec)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
results = append(results, payload)
}
jsonObj(c, results, nil)
}
func (a *ClientController) create(c *gin.Context) {
var payload service.ClientCreatePayload
if err := c.ShouldBindJSON(&payload); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
// Flagged before the error check: a partly-applied create leaves clients
// committed on the inbounds that succeeded, and those still need the restart.
if needRestart {
a.xrayService.SetToNeedRestart()
}
// A partly-applied call committed real clients; a rejected one touched
// nothing, and broadcasting those would refetch every panel for nothing.
if needRestart || err == nil {
notifyClientsChanged()
}
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
}
func (a *ClientController) update(c *gin.Context) {
email := c.Param("email")
var req struct {
model.Client
LimitHwid int `json:"limitHwid"`
}
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
// Flagged before the error check: a partly-applied edit leaves the change
// committed on the inbounds that succeeded, and those still need the restart.
if needRestart {
a.xrayService.SetToNeedRestart()
}
// A partly-applied call committed real changes; a rejected one touched
// nothing, and broadcasting those would refetch every panel for nothing.
if needRestart || err == nil {
notifyClientsChanged()
}
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
}
func (a *ClientController) delete(c *gin.Context) {
email := c.Param("email")
keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
// Flagged before the error check: a partly-applied delete already removed
// the client from the inbounds that succeeded, and those need the restart.
if needRestart {
a.xrayService.SetToNeedRestart()
}
// A partly-applied call committed real removals; a rejected one touched
// nothing, and broadcasting those would refetch every panel for nothing.
if needRestart || err == nil {
notifyClientsChanged()
}
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
}
type attachDetachBody struct {
InboundIds []int `json:"inboundIds"`
}
type externalLinksBody struct {
ExternalLinks []service.ExternalLinkInput `json:"externalLinks"`
}
func (a *ClientController) attach(c *gin.Context) {
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.AttachByEmail(&a.inboundService, email, body.InboundIds)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// A partly-applied call committed real clients; a rejected one touched
// nothing, and broadcasting those would refetch every panel for nothing.
if needRestart || err == nil {
notifyClientsChanged()
}
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
}
func (a *ClientController) setExternalLinks(c *gin.Context) {
email := c.Param("email")
var body externalLinksBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.clientService.SetExternalLinksByEmail(email, body.ExternalLinks); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
notifyClientsChanged()
}
func (a *ClientController) resetAllTraffics(c *gin.Context) {
needRestart, err := a.clientService.ResetAllTraffics()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type bulkAdjustRequest struct {
Emails []string `json:"emails"`
AddDays int `json:"addDays"`
AddBytes int64 `json:"addBytes"`
Flow string `json:"flow"`
LimitHwid *int `json:"limitHwid"`
AdTag string `json:"adTag"`
}
func (a *ClientController) bulkAdjust(c *gin.Context) {
var req bulkAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes, req.Flow, req.LimitHwid, req.AdTag)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type bulkDeleteRequest struct {
Emails []string `json:"emails"`
KeepTraffic bool `json:"keepTraffic"`
}
type bulkAttachRequest struct {
Emails []string `json:"emails"`
InboundIds []int `json:"inboundIds"`
}
func (a *ClientController) bulkAttach(c *gin.Context) {
var req bulkAttachRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkAttach(&a.inboundService, req.Emails, req.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type bulkDetachRequest struct {
Emails []string `json:"emails"`
InboundIds []int `json:"inboundIds"`
}
func (a *ClientController) bulkDetach(c *gin.Context) {
var req bulkDetachRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkDetach(&a.inboundService, req.Emails, req.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) bulkDelete(c *gin.Context) {
var req bulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkDelete(&a.inboundService, req.Emails, req.KeepTraffic)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type bulkEnableRequest struct {
Emails []string `json:"emails"`
}
func (a *ClientController) bulkEnable(c *gin.Context) {
a.bulkSetEnable(c, true)
}
func (a *ClientController) bulkDisable(c *gin.Context) {
a.bulkSetEnable(c, false)
}
func (a *ClientController) bulkSetEnable(c *gin.Context, enable bool) {
var req bulkEnableRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkSetEnable(&a.inboundService, req.Emails, enable)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) bulkCreate(c *gin.Context) {
var payloads []service.ClientCreatePayload
if err := c.ShouldBindJSON(&payloads); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkCreate(&a.inboundService, payloads)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) delDepleted(c *gin.Context) {
deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"deleted": deleted}, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
// export returns every client as a {client, inboundIds} list in the standard
// envelope. The frontend renders it in a read-only CodeMirror viewer (Copy /
// Download), so this hands back data rather than streaming a file attachment.
func (a *ClientController) export(c *gin.Context) {
items, err := a.clientService.ExportAll()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, items, nil)
}
type importClientsRequest struct {
Data string `json:"data"`
}
// importClients accepts the pasted export text as a JSON body { "data": "..." },
// mirroring the inbound import flow. The data string is itself a JSON-encoded
// []ClientCreatePayload, so it is unmarshalled in a second step.
func (a *ClientController) importClients(c *gin.Context) {
var req importClientsRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
var items []service.ClientCreatePayload
if err := json.Unmarshal([]byte(req.Data), &items); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.ImportClients(&a.inboundService, items)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) delOrphans(c *gin.Context) {
deleted, err := a.clientService.DeleteOrphans()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"deleted": deleted}, nil)
notifyClientsChanged()
}
func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
email := c.Param("email")
needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type trafficUpdateRequest struct {
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
email := c.Param("email")
var req trafficUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.inboundService.UpdateClientTrafficByEmail(email, req.Upload, req.Download); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
notifyClientsChanged()
}
func (a *ClientController) getIps(c *gin.Context) {
email := c.Param("email")
infos, err := a.inboundService.GetClientIpsWithNodes(email)
jsonObj(c, infos, err)
}
func (a *ClientController) clientIpsByGuid(c *gin.Context) {
data, err := a.inboundService.GetClientIpsByGuid()
jsonObj(c, data, err)
}
func (a *ClientController) clearIps(c *gin.Context) {
email := c.Param("email")
if err := a.inboundService.ClearClientIps(email); err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
}
func (a *ClientController) getHwids(c *gin.Context) {
infos, err := a.clientService.ListClientHwids(c.Param("email"))
jsonObj(c, infos, err)
}
func (a *ClientController) clearHwids(c *gin.Context) {
if err := a.clientService.ClearClientHwids(c.Param("email")); err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
}
func (a *ClientController) deleteHwid(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.clientService.DeleteClientHwid(c.Param("email"), id); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.clients.hwidDeleted"), nil)
}
func (a *ClientController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
func (a *ClientController) onlinesByGuid(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
}
func (a *ClientController) activeInbounds(c *gin.Context) {
jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
}
func (a *ClientController) lastOnline(c *gin.Context) {
data, err := a.inboundService.GetClientsLastOnline()
jsonObj(c, data, err)
}
func (a *ClientController) getTrafficByEmail(c *gin.Context) {
email := c.Param("email")
traffic, err := a.inboundService.GetClientTrafficByEmail(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, traffic, nil)
}
func (a *ClientController) getSubLinks(c *gin.Context) {
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
func (a *ClientController) getClientLinks(c *gin.Context) {
links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
func (a *ClientController) generateHappLink(c *gin.Context) {
c.Header("Cache-Control", "no-store")
clientID, err := strconv.Atoi(c.Param("id"))
if err != nil || clientID < 1 {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), service.ErrHappLinkUnavailable)
return
}
result, err := a.happGenerator.Generate(c.Request.Context(), clientID, c.Request.Host)
if err != nil {
if errors.Is(err, service.ErrHappSourceTooLong) {
// Keep the code exact so clients can localize it without exposing internal error details.
c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "happ_source_too_long", Obj: nil})
return
}
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), service.ErrHappLinkUnavailable)
return
}
jsonObj(c, result, nil)
}
func (a *ClientController) detach(c *gin.Context) {
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.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
// Flagged before the error check: a partly-applied detach already removed
// the client from the inbounds that succeeded, and those need the restart.
if needRestart {
a.xrayService.SetToNeedRestart()
}
// A partly-applied call committed real removals; a rejected one touched
// nothing, and broadcasting those would refetch every panel for nothing.
if needRestart || err == nil {
notifyClientsChanged()
}
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
}
type bulkResetRequest struct {
Emails []string `json:"emails"`
}
func (a *ClientController) bulkResetTraffic(c *gin.Context) {
var req bulkResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.BulkResetTraffic(&a.inboundService, req.Emails)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}