This commit is contained in:
shanshanzhong 2025-10-14 08:17:02 -07:00
parent 42061e38c0
commit dca32cd11c
21 changed files with 374 additions and 85 deletions

67
1.conf
View File

@ -1,67 +0,0 @@
server {
listen 80;
server_name api.hifast.biz 4d3vsw8.88xgaen.hifast.biz;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name api.hifast.biz;
client_max_body_size 150M;
ssl_certificate /etc/letsencrypt/live/api.hifast.biz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.hifast.biz/privkey.pem;
# 安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# de99e242子域名指向3001 (管理界面)
server {
listen 443 ssl http2;
server_name 4d3vsw8.88xgaen.hifast.biz;
client_max_body_size 150M;
ssl_certificate /etc/letsencrypt/live/4d3vsw8.88xgaen.hifast.biz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/4d3vsw8.88xgaen.hifast.biz/privkey.pem;
# 安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json image/svg+xml;
location ^~ / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_http_version 1.1;
add_header X-Cache $upstream_cache_status;
add_header Cache-Control no-cache;
proxy_ssl_server_name off;
proxy_ssl_name $proxy_host;
}
}

View File

@ -24,7 +24,7 @@ RUN BUILD_TIME=$(date -u +"%Y-%m-%d %H:%M:%S") && \
go build -ldflags="-s -w -X 'github.com/perfect-panel/server/pkg/constant.Version=${VERSION}' -X 'github.com/perfect-panel/server/pkg/constant.BuildTime=${BUILD_TIME}'" -o /app/ppanel ppanel.go
# Final minimal image
FROM scratch
FROM alpine:latest
# Copy CA certificates and timezone data
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

View File

@ -14,6 +14,43 @@ type (
QuerySubscribeListRequest {
Language string `form:"language"`
}
QueryUserSubscribeNodeListResponse {
List []UserSubscribeInfo `json:"list"`
}
UserSubscribeInfo {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
FinishedAt int64 `json:"finished_at"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
IsTryOut bool `json:"is_try_out"`
Nodes []*UserSubscribeNodeInfo `json:"nodes"`
}
UserSubscribeNodeInfo{
Id int64 `json:"id"`
Name string `json:"name"`
Uuid string `json:"uuid"`
Protocol string `json:"protocol"`
Port uint16 `json:"port"`
Address string `json:"address"`
Tags []string `json:"tags"`
Country string `json:"country"`
City string `json:"city"`
CreatedAt int64 `json:"created_at"`
}
)
@server (
@ -25,5 +62,9 @@ service ppanel {
@doc "Get subscribe list"
@handler QuerySubscribeList
get /list (QuerySubscribeListRequest) returns (QuerySubscribeListResponse)
@doc "Get user subscribe node info"
@handler QueryUserSubscribeNodeList
get /node/list returns (QueryUserSubscribeNodeListResponse)
}

View File

@ -656,7 +656,7 @@ type (
// public announcement
QueryAnnouncementRequest {
Page int `form:"page"`
Size int `form:"size"`
Size int `form:"size,default=15"`
Pinned *bool `form:"pinned"`
Popup *bool `form:"popup"`
}
@ -673,6 +673,7 @@ type (
List []SubscribeGroup `json:"list"`
Total int64 `json:"total"`
}
GetUserSubscribeTrafficLogsRequest {
Page int `form:"page"`
Size int `form:"size"`

View File

@ -0,0 +1,2 @@
ALTER TABLE `ads`
ADD COLUMN `description` VARCHAR(255) DEFAULT '' COMMENT 'Description';

View File

@ -0,0 +1,7 @@
INSERT INTO `ppanel`.`system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
SELECT 'site', 'CustomData', '{
"kr_website_id": ""
}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187'
WHERE NOT EXISTS (
SELECT 1 FROM `ppanel`.`system` WHERE `category` = 'site' AND `key` = 'CustomData'
);

View File

@ -0,0 +1,7 @@
INSERT INTO `ppanel`.`system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
SELECT 'site', 'CustomData', '{
"kr_website_id": ""
}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187'
WHERE NOT EXISTS (
SELECT 1 FROM `ppanel`.`system` WHERE `category` = 'site' AND `key` = 'CustomData'
);

View File

@ -0,0 +1,18 @@
package subscribe
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/subscribe"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
)
// Get user subscribe node info
func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx)
resp, err := l.QueryUserSubscribeNodeList()
result.HttpResult(c, resp, err)
}
}

View File

@ -739,6 +739,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
{
// Get subscribe list
publicSubscribeGroupRouter.GET("/list", publicSubscribe.QuerySubscribeListHandler(serverCtx))
// Get user subscribe node info
publicSubscribeGroupRouter.GET("/node/list", publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx))
}
publicTicketGroupRouter := router.Group("/v1/public/ticket")

View File

@ -35,11 +35,9 @@ func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Devic
}
func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *types.LoginResponse, err error) {
//TODO : check device login rate limit
// Check if device login is enabled
//if !l.svcCtx.Config.Register.EnableDevice {
// return nil, xerr.NewErrMsg("Device login is disabled")
//}
if !l.svcCtx.Config.Device.Enable {
return nil, xerr.NewErrMsg("Device login is disabled")
}
loginStatus := false
var userInfo *user.User

View File

@ -0,0 +1,195 @@
package subscribe
import (
"context"
"strings"
"time"
"github.com/perfect-panel/server/internal/model/node"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type QueryUserSubscribeNodeListLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get user subscribe node info
func NewQueryUserSubscribeNodeListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryUserSubscribeNodeListLogic {
return &QueryUserSubscribeNodeListLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryUserSubscribeNodeListLogic) QueryUserSubscribeNodeList() (resp *types.QueryUserSubscribeNodeListResponse, err error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
userSubscribes, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id, 1, 2)
if err != nil {
logger.Errorw("failed to query user subscribe", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "DB_ERROR")
}
resp = &types.QueryUserSubscribeNodeListResponse{}
for _, us := range userSubscribes {
userSubscribe, err := l.getUserSubscribe(us.Token)
if err != nil {
l.Errorw("[SubscribeLogic] Get user subscribe failed", logger.Field("error", err.Error()), logger.Field("token", userSubscribe.Token))
return nil, err
}
nodes, err := l.getServers(userSubscribe)
if err != nil {
return nil, err
}
userSubscribeInfo := types.UserSubscribeInfo{
Id: userSubscribe.Id,
Nodes: nodes,
Traffic: userSubscribe.Traffic,
Upload: userSubscribe.Upload,
Download: userSubscribe.Download,
Token: userSubscribe.Token,
UserId: userSubscribe.UserId,
OrderId: userSubscribe.OrderId,
SubscribeId: userSubscribe.SubscribeId,
StartTime: userSubscribe.StartTime.Unix(),
ExpireTime: userSubscribe.ExpireTime.Unix(),
Status: userSubscribe.Status,
CreatedAt: userSubscribe.CreatedAt.Unix(),
UpdatedAt: userSubscribe.UpdatedAt.Unix(),
}
if userSubscribe.FinishedAt != nil {
userSubscribeInfo.FinishedAt = userSubscribe.FinishedAt.Unix()
}
if l.svcCtx.Config.Register.EnableTrial && l.svcCtx.Config.Register.TrialSubscribe == userSubscribe.SubscribeId {
userSubscribeInfo.IsTryOut = true
}
resp.List = append(resp.List, userSubscribeInfo)
}
return
}
func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (userSubscribeNodes []*types.UserSubscribeNodeInfo, err error) {
userSubscribeNodes = make([]*types.UserSubscribeNodeInfo, 0)
if l.isSubscriptionExpired(userSub) {
return l.createExpiredServers(), nil
}
subDetails, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, userSub.SubscribeId)
if err != nil {
l.Errorw("[Generate Subscribe]find subscribe details error: %v", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe details error: %v", err.Error())
}
nodeIds := tool.StringToInt64Slice(subDetails.Nodes)
tags := strings.Split(subDetails.NodeTags, ",")
l.Debugf("[Generate Subscribe]nodes: %v, NodeTags: %v", nodeIds, tags)
enable := true
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
Page: 0,
Size: 1000,
NodeId: nodeIds,
Enabled: &enable, // Only get enabled nodes
})
if len(nodes) > 0 {
var serverMapIds = make(map[int64]*node.Server)
for _, n := range nodes {
serverMapIds[n.ServerId] = nil
}
var serverIds []int64
for k := range serverMapIds {
serverIds = append(serverIds, k)
}
servers, err := l.svcCtx.NodeModel.QueryServerList(l.ctx, serverIds)
if err != nil {
l.Errorw("[Generate Subscribe]find server details error: %v", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find server details error: %v", err.Error())
}
for _, s := range servers {
serverMapIds[s.Id] = s
}
for _, n := range nodes {
server := serverMapIds[n.ServerId]
if server == nil {
continue
}
userSubscribeNode := &types.UserSubscribeNodeInfo{
Id: n.Id,
Name: n.Name,
Uuid: userSub.UUID,
Protocol: n.Protocol,
Port: n.Port,
Address: n.Address,
Tags: strings.Split(n.Tags, ","),
Country: server.Country,
City: server.City,
CreatedAt: n.CreatedAt.Unix(),
}
userSubscribeNodes = append(userSubscribeNodes, userSubscribeNode)
}
}
l.Debugf("[Query Subscribe]found servers: %v", len(nodes))
if err != nil {
l.Errorw("[Generate Subscribe]find server details error: %v", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find server details error: %v", err.Error())
}
logger.Debugf("[Generate Subscribe]found servers: %v", len(nodes))
return userSubscribeNodes, nil
}
func (l *QueryUserSubscribeNodeListLogic) isSubscriptionExpired(userSub *user.Subscribe) bool {
return userSub.ExpireTime.Unix() < time.Now().Unix() && userSub.ExpireTime.Unix() != 0
}
func (l *QueryUserSubscribeNodeListLogic) createExpiredServers() []*types.UserSubscribeNodeInfo {
return nil
}
func (l *QueryUserSubscribeNodeListLogic) getFirstHostLine() string {
host := l.svcCtx.Config.Host
lines := strings.Split(host, "\n")
if len(lines) > 0 {
return lines[0]
}
return host
}
func (l *QueryUserSubscribeNodeListLogic) getUserSubscribe(token string) (*user.Subscribe, error) {
userSub, err := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
if err != nil {
l.Infow("[Generate Subscribe]find subscribe error: %v", logger.Field("error", err.Error()), logger.Field("token", token))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error())
}
// Ignore expiration check
//if userSub.Status > 1 {
// l.Infow("[Generate Subscribe]subscribe is not available", logger.Field("status", int(userSub.Status)), logger.Field("token", token))
// return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNotAvailable), "subscribe is not available")
//}
return userSub, nil
}

View File

@ -2,7 +2,9 @@ package user
import (
"context"
"fmt"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
@ -10,6 +12,7 @@ import (
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type UnbindDeviceLogic struct {
@ -38,5 +41,32 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device not belong to user")
}
return l.svcCtx.UserModel.DeleteDevice(l.ctx, req.Id)
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
var deleteDevice user.Device
err = tx.Model(&deleteDevice).Where("id = ?", req.Id).First(&deleteDevice).Error
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.QueueEnqueueError), "find device err: %v", err)
}
err = tx.Delete(deleteDevice).Error
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete device err: %v", err)
}
var userAuth user.AuthMethods
err = tx.Model(&userAuth).Where("auth_identifier = ? and auth_type = ?", deleteDevice.Identifier, "device").First(&userAuth).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find device online record err: %v", err)
}
err = tx.Delete(&userAuth).Error
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete device online record err: %v", err)
}
sessionId := l.ctx.Value(constant.CtxKeySessionID)
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey)
return nil
})
}

View File

@ -28,22 +28,30 @@ const (
func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
loginType := c.GetString(string(constant.LoginType))
if loginType == "" {
loginType = c.GetHeader("Login-Type")
}
if loginType != "device" {
if !srvCtx.Config.Device.Enable {
c.Next()
return
}
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), constant.LoginType, loginType))
if srvCtx.Config.Device.SecuritySecret == "" {
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.SecretIsEmpty), "Secret is empty"))
c.Abort()
return
}
if !srvCtx.Config.Device.Enable || srvCtx.Config.Device.SecuritySecret == "" {
ctx := c.Request.Context()
if ctx.Value(constant.CtxKeyUser) == nil && c.GetHeader("Login-Type") != "" {
ctx = context.WithValue(ctx, constant.LoginType, c.GetHeader("Login-Type"))
c.Request = c.Request.WithContext(ctx)
}
loginType, ok := ctx.Value(constant.LoginType).(string)
if !ok || loginType != "device" {
c.Next()
return
}
rw := NewResponseWriter(c, srvCtx)
if !rw.Decrypt() {
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidCiphertext), "Invalid ciphertext"))

View File

@ -43,7 +43,7 @@ func (m *customAnnouncementModel) GetAnnouncementListByPage(ctx context.Context,
if filter.Search != "" {
conn = conn.Where("`title` LIKE ? OR `content` LIKE ?", "%"+filter.Search+"%", "%"+filter.Search+"%")
}
return conn.Count(&total).Offset((page - 1) * size).Limit(size).Find(v).Error
return conn.Count(&total).Offset((page - 1) * size).Limit(size).Find(&list).Error
})
return total, list, err
}

View File

@ -23,6 +23,7 @@ type (
UpdateServer(ctx context.Context, data *Server, tx ...*gorm.DB) error
DeleteServer(ctx context.Context, id int64, tx ...*gorm.DB) error
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
QueryServerList(ctx context.Context, ids []int64) (servers []*Server, err error)
}
NodeModel interface {

View File

@ -65,6 +65,12 @@ func (m *customServerModel) FilterServerList(ctx context.Context, params *Filter
return total, servers, err
}
func (m *customServerModel) QueryServerList(ctx context.Context, ids []int64) (servers []*Server, err error) {
query := m.WithContext(ctx).Model(&Server{})
err = query.Where("id IN (?)", ids).Find(&servers).Error
return
}
// FilterNodeList Filter Node List
func (m *customServerModel) FilterNodeList(ctx context.Context, params *FilterNodeParams) (int64, []*Node, error) {
var nodes []*Node

View File

@ -51,7 +51,7 @@ func (m *customUserModel) QueryDeviceList(ctx context.Context, userId int64) ([]
var list []*Device
var total int64
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&Device{}).Where("`user_id` = ? and `subscribe_id` = ?", userId).Count(&total).Find(&list).Error
return conn.Model(&Device{}).Where("`user_id` = ?", userId).Count(&total).Find(&list).Error
})
return list, total, err
}

View File

@ -1540,7 +1540,7 @@ type PurchaseOrderResponse struct {
type QueryAnnouncementRequest struct {
Page int `form:"page"`
Size int `form:"size"`
Size int `form:"size,default=15"`
Pinned *bool `form:"pinned"`
Popup *bool `form:"popup"`
}
@ -1697,6 +1697,10 @@ type QueryUserSubscribeListResponse struct {
Total int64 `json:"total"`
}
type QueryUserSubscribeNodeListResponse struct {
List []UserSubscribeInfo `json:"list"`
}
type QuotaTask struct {
Id int64 `json:"id"`
Subscribers []int64 `json:"subscribers"`
@ -2595,6 +2599,26 @@ type UserSubscribeDetail struct {
UpdatedAt int64 `json:"updated_at"`
}
type UserSubscribeInfo struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
FinishedAt int64 `json:"finished_at"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
IsTryOut bool `json:"is_try_out"`
Nodes []*UserSubscribeNodeInfo `json:"nodes"`
}
type UserSubscribeLog struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
@ -2605,6 +2629,19 @@ type UserSubscribeLog struct {
Timestamp int64 `json:"timestamp"`
}
type UserSubscribeNodeInfo struct {
Id int64 `json:"id"`
Name string `json:"name"`
Uuid string `json:"uuid"`
Protocol string `json:"protocol"`
Port uint16 `json:"port"`
Address string `json:"address"`
Tags []string `json:"tags"`
Country string `json:"country"`
City string `json:"city"`
CreatedAt int64 `json:"created_at"`
}
type UserSubscribeTrafficLog struct {
SubscribeId int64 `json:"subscribe_id"` // Subscribe ID
UserId int64 `json:"user_id"` // User ID

View File

@ -47,6 +47,7 @@ const (
ErrorTokenExpire uint32 = 40004
InvalidAccess uint32 = 40005
InvalidCiphertext uint32 = 40006
SecretIsEmpty uint32 = 40007
)
//coupon error

View File

@ -14,6 +14,7 @@ func init() {
ErrorTokenEmpty: "User token is empty",
ErrorTokenInvalid: "User token is invalid",
ErrorTokenExpire: "User token is expired",
SecretIsEmpty: "Secret is empty",
InvalidAccess: "Invalid access",
InvalidCiphertext: "Invalid ciphertext",
// Database error