334 lines
9.2 KiB
Go
334 lines
9.2 KiB
Go
package registry
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
packageNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
|
versionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$`)
|
|
fileNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
|
)
|
|
|
|
type Package struct {
|
|
Project string `json:"project,omitempty"`
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Description string `json:"description,omitempty"`
|
|
Platform string `json:"platform,omitempty"`
|
|
Architecture string `json:"architecture,omitempty"`
|
|
PackageType string `json:"package_type,omitempty"`
|
|
Channel string `json:"channel,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
FileName string `json:"file_name,omitempty"`
|
|
UploaderID string `json:"uploader_id,omitempty"`
|
|
UploaderEmail string `json:"uploader_email,omitempty"`
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
UploadedAt time.Time `json:"uploaded_at"`
|
|
Targets []Target `json:"targets,omitempty"`
|
|
}
|
|
|
|
type Target struct {
|
|
ID string `json:"id"`
|
|
Platform string `json:"platform"`
|
|
Architecture string `json:"architecture"`
|
|
PackageType string `json:"package_type"`
|
|
FileName string `json:"file_name"`
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
ArtifactID string `json:"artifact_id"`
|
|
Entrypoint string `json:"entrypoint,omitempty"`
|
|
UpgradeFrom []string `json:"upgrade_from,omitempty"`
|
|
}
|
|
|
|
type Update struct {
|
|
Package Package `json:"package"`
|
|
}
|
|
|
|
type Registry struct {
|
|
root string
|
|
mu sync.RWMutex
|
|
writeMu sync.Mutex
|
|
packages map[string]map[string]Package
|
|
subs map[int]chan Update
|
|
nextSub int
|
|
}
|
|
|
|
func New(root string) (*Registry, error) {
|
|
if root == "" {
|
|
return nil, errors.New("存储目录不能为空")
|
|
}
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return nil, fmt.Errorf("创建存储目录失败: %w", err)
|
|
}
|
|
registry := &Registry{
|
|
root: root,
|
|
packages: make(map[string]map[string]Package),
|
|
subs: make(map[int]chan Update),
|
|
}
|
|
if err := registry.load(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := registry.loadReleases(); err != nil {
|
|
return nil, err
|
|
}
|
|
return registry, nil
|
|
}
|
|
|
|
func ValidatePackage(name, version string) error {
|
|
if !packageNamePattern.MatchString(name) {
|
|
return errors.New("包名只能包含字母、数字、点、下划线和短横线,长度为 1 到 64")
|
|
}
|
|
if !versionPattern.MatchString(version) {
|
|
return errors.New("版本只能包含字母、数字、点、加号、下划线和短横线,长度为 1 到 64")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateFileName(fileName string) error {
|
|
fileName = filepath.Base(strings.TrimSpace(fileName))
|
|
if fileName == "." || fileName == "" || !fileNamePattern.MatchString(fileName) {
|
|
return errors.New("包文件名无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Registry) addLegacyPackage(input Package, data io.Reader) (Package, error) {
|
|
if err := ValidatePackage(input.Name, input.Version); err != nil {
|
|
return Package{}, err
|
|
}
|
|
if data == nil {
|
|
return Package{}, errors.New("包文件不能为空")
|
|
}
|
|
if input.FileName == "" {
|
|
input.FileName = input.Name + "-" + input.Version + ".pkg"
|
|
}
|
|
input.FileName = filepath.Base(input.FileName)
|
|
if err := ValidateFileName(input.FileName); err != nil {
|
|
return Package{}, err
|
|
}
|
|
|
|
r.writeMu.Lock()
|
|
defer r.writeMu.Unlock()
|
|
packageDir := filepath.Join(r.root, input.Name)
|
|
if err := os.MkdirAll(packageDir, 0o755); err != nil {
|
|
return Package{}, fmt.Errorf("创建包目录失败: %w", err)
|
|
}
|
|
versionDir := filepath.Join(packageDir, input.Version)
|
|
packagePath := filepath.Join(versionDir, "package.bin")
|
|
metadataPath := filepath.Join(versionDir, "metadata.json")
|
|
if _, err := os.Stat(packagePath); err == nil {
|
|
return Package{}, errors.New("该包版本已经存在")
|
|
} else if !os.IsNotExist(err) {
|
|
return Package{}, fmt.Errorf("检查包版本失败: %w", err)
|
|
}
|
|
temp, err := os.CreateTemp(packageDir, ".upload-*")
|
|
if err != nil {
|
|
return Package{}, fmt.Errorf("创建临时文件失败: %w", err)
|
|
}
|
|
tempName := temp.Name()
|
|
defer os.Remove(tempName)
|
|
|
|
hash := sha256.New()
|
|
size, err := io.Copy(io.MultiWriter(temp, hash), data)
|
|
if err != nil {
|
|
temp.Close()
|
|
return Package{}, fmt.Errorf("写入包文件失败: %w", err)
|
|
}
|
|
if size == 0 {
|
|
temp.Close()
|
|
return Package{}, errors.New("包文件不能为空")
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return Package{}, fmt.Errorf("关闭包文件失败: %w", err)
|
|
}
|
|
|
|
input.Size = size
|
|
input.SHA256 = hex.EncodeToString(hash.Sum(nil))
|
|
input.UploadedAt = time.Now().UTC()
|
|
if err := os.MkdirAll(versionDir, 0o755); err != nil {
|
|
return Package{}, fmt.Errorf("创建版本目录失败: %w", err)
|
|
}
|
|
if err := os.Rename(tempName, packagePath); err != nil {
|
|
return Package{}, fmt.Errorf("保存包文件失败: %w", err)
|
|
}
|
|
metadata, err := json.MarshalIndent(input, "", " ")
|
|
if err != nil {
|
|
return Package{}, fmt.Errorf("编码包元数据失败: %w", err)
|
|
}
|
|
if err := os.WriteFile(metadataPath, metadata, 0o644); err != nil {
|
|
os.Remove(packagePath)
|
|
return Package{}, fmt.Errorf("保存包元数据失败: %w", err)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if r.packages[input.Name] == nil {
|
|
r.packages[input.Name] = make(map[string]Package)
|
|
}
|
|
r.packages[input.Name][input.Version] = input
|
|
subs := make([]chan Update, 0, len(r.subs))
|
|
for _, subscriber := range r.subs {
|
|
subs = append(subs, subscriber)
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
update := Update{Package: input}
|
|
for _, subscriber := range subs {
|
|
select {
|
|
case subscriber <- update:
|
|
default:
|
|
}
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func (r *Registry) Latest(name string) (Package, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
versions := r.packages[name]
|
|
if len(versions) == 0 {
|
|
return Package{}, false
|
|
}
|
|
keys := make([]string, 0, len(versions))
|
|
for version := range versions {
|
|
keys = append(keys, version)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool { return CompareVersions(keys[i], keys[j]) > 0 })
|
|
return versions[keys[0]], true
|
|
}
|
|
|
|
func (r *Registry) Versions(name string) []Package {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
versions := r.packages[name]
|
|
result := make([]Package, 0, len(versions))
|
|
for _, item := range versions {
|
|
result = append(result, item)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return CompareVersions(result[i].Version, result[j].Version) > 0 })
|
|
return result
|
|
}
|
|
|
|
func (r *Registry) Get(name, version string) (Package, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
item, ok := r.packages[name][version]
|
|
return item, ok
|
|
}
|
|
|
|
func (r *Registry) OpenPackage(name, version string) (*os.File, Package, error) {
|
|
return r.OpenPackageFor(name, version, "", "", "")
|
|
}
|
|
|
|
func (r *Registry) Subscribe() (<-chan Update, func()) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
id := r.nextSub
|
|
r.nextSub++
|
|
channel := make(chan Update, 16)
|
|
r.subs[id] = channel
|
|
return channel, func() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if existing, ok := r.subs[id]; ok {
|
|
delete(r.subs, id)
|
|
close(existing)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Registry) load() error {
|
|
entries, err := os.ReadDir(r.root)
|
|
if err != nil {
|
|
return fmt.Errorf("读取存储目录失败: %w", err)
|
|
}
|
|
for _, packageEntry := range entries {
|
|
if !packageEntry.IsDir() || !packageNamePattern.MatchString(packageEntry.Name()) {
|
|
continue
|
|
}
|
|
versions, err := os.ReadDir(filepath.Join(r.root, packageEntry.Name()))
|
|
if err != nil {
|
|
return fmt.Errorf("读取包目录失败: %w", err)
|
|
}
|
|
for _, versionEntry := range versions {
|
|
if !versionEntry.IsDir() || !versionPattern.MatchString(versionEntry.Name()) {
|
|
continue
|
|
}
|
|
metadataPath := filepath.Join(r.root, packageEntry.Name(), versionEntry.Name(), "metadata.json")
|
|
metadata, err := os.ReadFile(metadataPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var item Package
|
|
if json.Unmarshal(metadata, &item) == nil {
|
|
if r.packages[item.Name] == nil {
|
|
r.packages[item.Name] = make(map[string]Package)
|
|
}
|
|
r.packages[item.Name][item.Version] = item
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func CompareVersions(left, right string) int {
|
|
leftParts := splitVersion(left)
|
|
rightParts := splitVersion(right)
|
|
for index := 0; index < len(leftParts) || index < len(rightParts); index++ {
|
|
if index >= len(leftParts) {
|
|
return -1
|
|
}
|
|
if index >= len(rightParts) {
|
|
return 1
|
|
}
|
|
leftNumber, leftOK := numericPart(leftParts[index])
|
|
rightNumber, rightOK := numericPart(rightParts[index])
|
|
if leftOK && rightOK {
|
|
if leftNumber != rightNumber {
|
|
if leftNumber > rightNumber {
|
|
return 1
|
|
}
|
|
return -1
|
|
}
|
|
continue
|
|
}
|
|
if leftParts[index] != rightParts[index] {
|
|
return strings.Compare(leftParts[index], rightParts[index])
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func splitVersion(version string) []string {
|
|
version = strings.TrimPrefix(version, "v")
|
|
version = strings.SplitN(version, "+", 2)[0]
|
|
return strings.FieldsFunc(version, func(r rune) bool { return r == '.' || r == '-' || r == '_' })
|
|
}
|
|
|
|
func numericPart(value string) (int64, bool) {
|
|
var number int64
|
|
if value == "" {
|
|
return 0, false
|
|
}
|
|
for _, char := range value {
|
|
if char < '0' || char > '9' {
|
|
return 0, false
|
|
}
|
|
number = number*10 + int64(char-'0')
|
|
}
|
|
return number, true
|
|
}
|