package db import ( "database/sql" "fmt" "time" "github.com/google/uuid" "github.com/orohi/vpn-panel/internal/models" "github.com/orohi/vpn-panel/internal/secretbox" ) func scanClient(scanner interface { Scan(dest ...any) error }) (*models.Client, error) { c := &models.Client{} var expire sql.NullTime var status string err := scanner.Scan( &c.ID, &c.Username, &c.Email, &c.UUID, &status, &c.TrafficLimitBytes, &c.TrafficUsedBytes, &expire, &c.SubToken, &c.Note, &c.CreatedAt, &c.UpdatedAt, &c.InboundCount, ) if err != nil { return nil, err } c.Status = models.ClientStatus(status) if expire.Valid { t := expire.Time c.ExpireAt = &t } return c, nil } const clientColumns = ` c.id, c.username, c.email, c.uuid, c.status, c.traffic_limit_bytes, c.traffic_used_bytes, c.expire_at, c.sub_token, c.note, c.created_at, c.updated_at, (SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)` func CreateClient(db *sql.DB, c *models.Client, inboundIDs []uuid.UUID) error { if c.ID == uuid.Nil { c.ID = uuid.New() } if c.UUID == uuid.Nil { c.UUID = uuid.New() } if c.SubToken == "" { tok, err := secretbox.RandomToken(24) if err != nil { return err } c.SubToken = tok } if c.Status == "" { c.Status = models.ClientStatusActive } now := time.Now().UTC() c.CreatedAt = now c.UpdatedAt = now tx, err := db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() _, err = tx.Exec(` INSERT INTO clients ( id, username, email, uuid, status, traffic_limit_bytes, traffic_used_bytes, expire_at, sub_token, note, created_at, updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, c.ID, c.Username, c.Email, c.UUID, string(c.Status), c.TrafficLimitBytes, c.TrafficUsedBytes, c.ExpireAt, c.SubToken, c.Note, c.CreatedAt, c.UpdatedAt, ) if err != nil { return err } for _, iid := range inboundIDs { if _, err := tx.Exec(`INSERT INTO client_inbounds (client_id, inbound_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, c.ID, iid); err != nil { return err } } return tx.Commit() } func UpdateClient(db *sql.DB, c *models.Client, inboundIDs []uuid.UUID) error { tx, err := db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() _, err = tx.Exec(` UPDATE clients SET username = $2, email = $3, status = $4, traffic_limit_bytes = $5, expire_at = $6, note = $7, updated_at = NOW() WHERE id = $1`, c.ID, c.Username, c.Email, string(c.Status), c.TrafficLimitBytes, c.ExpireAt, c.Note, ) if err != nil { return err } if _, err := tx.Exec(`DELETE FROM client_inbounds WHERE client_id = $1`, c.ID); err != nil { return err } for _, iid := range inboundIDs { if _, err := tx.Exec(`INSERT INTO client_inbounds (client_id, inbound_id) VALUES ($1, $2)`, c.ID, iid); err != nil { return err } } return tx.Commit() } func GetClient(db *sql.DB, id uuid.UUID) (*models.Client, error) { row := db.QueryRow(`SELECT `+clientColumns+` FROM clients c WHERE c.id = $1`, id) c, err := scanClient(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } ids, err := ListClientInboundIDs(db, id) if err != nil { return nil, err } c.InboundIDs = ids return c, nil } func GetClientBySubToken(db *sql.DB, token string) (*models.Client, error) { row := db.QueryRow(`SELECT `+clientColumns+` FROM clients c WHERE c.sub_token = $1`, token) c, err := scanClient(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } ids, err := ListClientInboundIDs(db, c.ID) if err != nil { return nil, err } c.InboundIDs = ids return c, nil } func ListClients(db *sql.DB) ([]models.Client, error) { rows, err := db.Query(`SELECT ` + clientColumns + ` FROM clients c ORDER BY c.created_at DESC`) if err != nil { return nil, err } defer rows.Close() var list []models.Client for rows.Next() { c, err := scanClient(rows) if err != nil { return nil, err } list = append(list, *c) } return list, rows.Err() } func ListClientInboundIDs(db *sql.DB, clientID uuid.UUID) ([]uuid.UUID, error) { rows, err := db.Query(`SELECT inbound_id FROM client_inbounds WHERE client_id = $1`, clientID) if err != nil { return nil, err } defer rows.Close() var ids []uuid.UUID for rows.Next() { var id uuid.UUID if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } func SetClientStatus(db *sql.DB, id uuid.UUID, status models.ClientStatus) error { _, err := db.Exec(`UPDATE clients SET status = $2, updated_at = NOW() WHERE id = $1`, id, string(status)) return err } func DeleteClient(db *sql.DB, id uuid.UUID) error { res, err := db.Exec(`DELETE FROM clients WHERE id = $1`, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("client not found") } return nil } func ListAllInbounds(db *sql.DB) ([]models.Inbound, error) { rows, err := db.Query(` SELECT ` + inboundColumns + ` FROM inbounds i JOIN protocols p ON p.id = i.protocol_id WHERE i.enabled = TRUE ORDER BY i.sort_order ASC, i.tag ASC`) if err != nil { return nil, err } defer rows.Close() var list []models.Inbound for rows.Next() { in, err := scanInbound(rows) if err != nil { return nil, err } list = append(list, *in) } return list, rows.Err() } // ClientSubscriptionLinks builds basic share links using online nodes that have the inbound enabled. func ClientSubscriptionLinks(db *sql.DB, client *models.Client) ([]string, error) { if client == nil || len(client.InboundIDs) == 0 { return nil, nil } rows, err := db.Query(` SELECT DISTINCT i.tag, p.code, i.port, i.network, i.security, n.host, n.name FROM client_inbounds ci JOIN inbounds i ON i.id = ci.inbound_id AND i.enabled = TRUE JOIN protocols p ON p.id = i.protocol_id JOIN node_inbounds ni ON ni.inbound_id = i.id AND ni.enabled = TRUE JOIN nodes n ON n.id = ni.node_id AND n.status = 'online' AND n.profile_id = i.profile_id WHERE ci.client_id = $1 ORDER BY n.name, i.tag`, client.ID) if err != nil { return nil, err } defer rows.Close() var links []string uid := client.UUID.String() for rows.Next() { var tag, code, network, security, host, nodeName string var port int if err := rows.Scan(&tag, &code, &port, &network, &security, &host, &nodeName); err != nil { return nil, err } name := client.Username + "-" + tag + "@" + nodeName switch code { case "vless": links = append(links, fmt.Sprintf( "vless://%s@%s:%d?encryption=none&type=%s&security=%s#%s", uid, host, port, network, security, name, )) case "vmess": links = append(links, fmt.Sprintf( "vmess://%s@%s:%d?type=%s&security=%s#%s", uid, host, port, network, security, name, )) case "trojan": links = append(links, fmt.Sprintf( "trojan://%s@%s:%d?type=%s&security=%s#%s", uid, host, port, network, security, name, )) case "shadowsocks": links = append(links, fmt.Sprintf( "ss://%s@%s:%d#%s", uid, host, port, name, )) default: links = append(links, fmt.Sprintf("%s://%s@%s:%d?type=%s&security=%s#%s", code, uid, host, port, network, security, name)) } } return links, rows.Err() }