Merge branch perfect-panel/master/server into develop

This commit is contained in:
EUForest
2025-12-11 23:53:32 +08:00
parent 5e46357104
commit 76ff9a658d
60 changed files with 1897 additions and 84 deletions
+4 -2
View File
@@ -2,6 +2,8 @@ package constant
// Version PPanel version
var (
Version = "unknown version"
BuildTime = "unknown time"
Version = "unknown version"
BuildTime = "unknown time"
Repository = "https://github.com/perfect-panel/server"
ServiceName = "ApiService"
)
+38
View File
@@ -0,0 +1,38 @@
package tool
import (
"crypto/sha256"
"encoding/binary"
"errors"
"math/rand"
)
func FixedUniqueString(s string, length int, alphabet string) (string, error) {
if alphabet == "" {
alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
}
if length <= 0 {
return "", errors.New("length must be > 0")
}
if length > len(alphabet) {
return "", errors.New("length greater than available unique characters")
}
// Generate deterministic seed from SHA256
hash := sha256.Sum256([]byte(s))
seed := int64(binary.LittleEndian.Uint64(hash[:8])) // 前 8 字节
r := rand.New(rand.NewSource(seed))
// Copy alphabet to mutable array
data := []rune(alphabet)
// Deterministic shuffle (FisherYates)
for i := len(data) - 1; i > 0; i-- {
j := r.Intn(i + 1)
data[i], data[j] = data[j], data[i]
}
// Take first N characters
return string(data[:length]), nil
}
+27
View File
@@ -0,0 +1,27 @@
package tool
import (
"testing"
)
func TestFixedUniqueString(t *testing.T) {
a := "example"
b := "example1"
c := "example"
strA1, err := FixedUniqueString(a, 8, "")
strB1, err := FixedUniqueString(b, 8, "")
strC1, err := FixedUniqueString(c, 8, "")
if err != nil {
t.Logf("Error: %v", err.Error())
return
}
if strA1 != strC1 {
t.Errorf("Expected strA1 and strC1 to be equal, got %s and %s", strA1, strC1)
}
if strA1 == strB1 {
t.Errorf("Expected strA1 and strB1 to be different, got %s and %s", strA1, strB1)
}
t.Logf("strA1 and strB1 are not equal, strA1: %s, strB1: %s", strA1, strB1)
t.Logf("strA1 and strC1 are equal,strA1: %s, strC1: %s", strA1, strC1)
}
+382
View File
@@ -0,0 +1,382 @@
package updater
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/perfect-panel/server/pkg/constant"
)
const (
githubAPIURL = "https://api.github.com/repos/OmnTeam/server/releases/latest"
githubRelURL = "https://github.com/OmnTeam/server/releases"
)
// Release represents a GitHub release
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Assets []Asset `json:"assets"`
HTMLURL string `json:"html_url"`
}
// Asset represents a release asset
type Asset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
}
// Updater handles auto-update functionality
type Updater struct {
CurrentVersion string
Owner string
Repo string
HTTPClient *http.Client
}
// NewUpdater creates a new updater instance
func NewUpdater() *Updater {
return &Updater{
CurrentVersion: constant.Version,
Owner: "OmnTeam",
Repo: "server",
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// CheckForUpdates checks if a new version is available
func (u *Updater) CheckForUpdates() (*Release, bool, error) {
req, err := http.NewRequest("GET", githubAPIURL, nil)
if err != nil {
return nil, false, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := u.HTTPClient.Do(req)
if err != nil {
return nil, false, fmt.Errorf("failed to fetch release info: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, false, fmt.Errorf("failed to decode response: %w", err)
}
// Skip draft and prerelease versions
if release.Draft || release.Prerelease {
return nil, false, nil
}
// Compare versions
hasUpdate := u.compareVersions(release.TagName, u.CurrentVersion)
return &release, hasUpdate, nil
}
// compareVersions compares two version strings
// Returns true if newVersion is newer than currentVersion
func (u *Updater) compareVersions(newVersion, currentVersion string) bool {
// Remove 'v' prefix if present
newVersion = strings.TrimPrefix(newVersion, "v")
currentVersion = strings.TrimPrefix(currentVersion, "v")
// Handle "unknown version" case
if currentVersion == "unknown version" || currentVersion == "" {
return true
}
return newVersion != currentVersion
}
// Download downloads the appropriate binary for the current platform
func (u *Updater) Download(release *Release) (string, error) {
assetName := u.getAssetName()
var targetAsset *Asset
for _, asset := range release.Assets {
if asset.Name == assetName {
targetAsset = &asset
break
}
}
if targetAsset == nil {
return "", fmt.Errorf("no suitable asset found for %s", assetName)
}
// Create temp directory
tempDir, err := os.MkdirTemp("", "ppanel-update-*")
if err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}
// Download the file
resp, err := u.HTTPClient.Get(targetAsset.BrowserDownloadURL)
if err != nil {
os.RemoveAll(tempDir)
return "", fmt.Errorf("failed to download asset: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
os.RemoveAll(tempDir)
return "", fmt.Errorf("failed to download: status code %d", resp.StatusCode)
}
// Read the entire file into memory
data, err := io.ReadAll(resp.Body)
if err != nil {
os.RemoveAll(tempDir)
return "", fmt.Errorf("failed to read download: %w", err)
}
// Extract the binary
binaryPath, err := u.extractBinary(data, tempDir, assetName)
if err != nil {
os.RemoveAll(tempDir)
return "", fmt.Errorf("failed to extract binary: %w", err)
}
return binaryPath, nil
}
// getAssetName returns the expected asset name for the current platform
func (u *Updater) getAssetName() string {
goos := runtime.GOOS
goarch := runtime.GOARCH
// Capitalize first letter of OS
osName := strings.Title(goos)
// Map architecture names to match goreleaser output
archName := goarch
switch goarch {
case "amd64":
archName = "x86_64"
case "386":
archName = "i386"
}
// Format: ppanel-server-{Version}-{Os}-{Arch}.{ext}
ext := "tar.gz"
if goos == "windows" {
ext = "zip"
}
return fmt.Sprintf("ppanel-server-%s-%s-%s.%s", u.CurrentVersion, osName, archName, ext)
}
// extractBinary extracts the binary from the downloaded archive
func (u *Updater) extractBinary(data []byte, destDir, assetName string) (string, error) {
if strings.HasSuffix(assetName, ".zip") {
return u.extractZip(data, destDir)
}
return u.extractTarGz(data, destDir)
}
// extractZip extracts a zip archive
func (u *Updater) extractZip(data []byte, destDir string) (string, error) {
reader := bytes.NewReader(data)
zipReader, err := zip.NewReader(reader, int64(len(data)))
if err != nil {
return "", fmt.Errorf("failed to create zip reader: %w", err)
}
var binaryPath string
for _, file := range zipReader.File {
// Look for the binary file
if strings.Contains(file.Name, "ppanel-server") && !strings.Contains(file.Name, "/") {
binaryPath = filepath.Join(destDir, filepath.Base(file.Name))
rc, err := file.Open()
if err != nil {
return "", fmt.Errorf("failed to open file in zip: %w", err)
}
defer rc.Close()
outFile, err := os.OpenFile(binaryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return "", fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
if _, err := io.Copy(outFile, rc); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return binaryPath, nil
}
}
return "", fmt.Errorf("binary not found in archive")
}
// extractTarGz extracts a tar.gz archive
func (u *Updater) extractTarGz(data []byte, destDir string) (string, error) {
reader := bytes.NewReader(data)
gzReader, err := gzip.NewReader(reader)
if err != nil {
return "", fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
var binaryPath string
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to read tar: %w", err)
}
// Look for the binary file
if strings.Contains(header.Name, "ppanel-server") && !strings.Contains(header.Name, "/") {
binaryPath = filepath.Join(destDir, filepath.Base(header.Name))
outFile, err := os.OpenFile(binaryPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return "", fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
if _, err := io.Copy(outFile, tarReader); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return binaryPath, nil
}
}
return "", fmt.Errorf("binary not found in archive")
}
// Apply applies the update by replacing the current binary
func (u *Updater) Apply(newBinaryPath string) error {
// Get current executable path
currentPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get current executable path: %w", err)
}
// Resolve symlinks
currentPath, err = filepath.EvalSymlinks(currentPath)
if err != nil {
return fmt.Errorf("failed to resolve symlinks: %w", err)
}
// Create backup
backupPath := currentPath + ".backup"
if err := u.copyFile(currentPath, backupPath); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
// Replace the binary
if err := u.replaceFile(newBinaryPath, currentPath); err != nil {
// Restore backup on failure
u.copyFile(backupPath, currentPath)
return fmt.Errorf("failed to replace binary: %w", err)
}
// Remove backup on success
os.Remove(backupPath)
return nil
}
// copyFile copies a file from src to dst
func (u *Updater) copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
return dstFile.Sync()
}
// replaceFile replaces dst with src
func (u *Updater) replaceFile(src, dst string) error {
// On Windows, we need to rename the old file first
if runtime.GOOS == "windows" {
oldPath := dst + ".old"
if err := os.Rename(dst, oldPath); err != nil {
return err
}
defer os.Remove(oldPath)
}
// Copy the new file
if err := u.copyFile(src, dst); err != nil {
return err
}
return nil
}
// Update performs the complete update process
func (u *Updater) Update() error {
// Check for updates
release, hasUpdate, err := u.CheckForUpdates()
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
if !hasUpdate {
return fmt.Errorf("already running the latest version")
}
fmt.Printf("New version available: %s\n", release.TagName)
fmt.Printf("Downloading update...\n")
// Download the update
binaryPath, err := u.Download(release)
if err != nil {
return fmt.Errorf("failed to download update: %w", err)
}
defer os.RemoveAll(filepath.Dir(binaryPath))
fmt.Printf("Applying update...\n")
// Apply the update
if err := u.Apply(binaryPath); err != nil {
return fmt.Errorf("failed to apply update: %w", err)
}
fmt.Printf("Update completed successfully! Please restart the application.\n")
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package updater
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewUpdater(t *testing.T) {
u := NewUpdater()
assert.NotNil(t, u)
assert.Equal(t, "OmnTeam", u.Owner)
assert.Equal(t, "server", u.Repo)
assert.NotNil(t, u.HTTPClient)
}
func TestCompareVersions(t *testing.T) {
u := NewUpdater()
tests := []struct {
name string
newVersion string
currentVersion string
expected bool
}{
{
name: "same version",
newVersion: "v1.0.0",
currentVersion: "v1.0.0",
expected: false,
},
{
name: "different version",
newVersion: "v1.1.0",
currentVersion: "v1.0.0",
expected: true,
},
{
name: "unknown current version",
newVersion: "v1.0.0",
currentVersion: "unknown version",
expected: true,
},
{
name: "version without v prefix",
newVersion: "1.1.0",
currentVersion: "1.0.0",
expected: true,
},
{
name: "empty current version",
newVersion: "v1.0.0",
currentVersion: "",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := u.compareVersions(tt.newVersion, tt.currentVersion)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGetAssetName(t *testing.T) {
u := NewUpdater()
u.CurrentVersion = "v1.0.0"
assetName := u.getAssetName()
assert.NotEmpty(t, assetName)
assert.Contains(t, assetName, "ppanel-server")
assert.Contains(t, assetName, "v1.0.0")
}
+11 -10
View File
@@ -18,16 +18,17 @@ const (
// User error
const (
UserExist uint32 = 20001
UserNotExist uint32 = 20002
UserPasswordError uint32 = 20003
UserDisabled uint32 = 20004
InsufficientBalance uint32 = 20005
StopRegister uint32 = 20006
TelegramNotBound uint32 = 20007
UserNotBindOauth uint32 = 20008
InviteCodeError uint32 = 20009
RegisterIPLimit uint32 = 20010
UserExist uint32 = 20001
UserNotExist uint32 = 20002
UserPasswordError uint32 = 20003
UserDisabled uint32 = 20004
InsufficientBalance uint32 = 20005
StopRegister uint32 = 20006
TelegramNotBound uint32 = 20007
UserNotBindOauth uint32 = 20008
InviteCodeError uint32 = 20009
UserCommissionNotEnough uint32 = 20010
RegisterIPLimit uint32 = 20011
)
// Node error