开发检查点:完善多项目发布中心基础能力

This commit is contained in:
Scout开发者 2026-08-31 19:03:43 +08:00
parent e0ad5ea50a
commit 82bf06eca6
14 changed files with 715 additions and 21 deletions

View File

@ -46,11 +46,12 @@ func main() {
func legacyCheckCommand(args []string) {
flags := flag.NewFlagSet("check", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "服务端地址")
project := flags.String("project", "", "项目标识;为空时使用兼容接口")
name := flags.String("package", "", "包名")
current := flags.String("current", "", "当前版本")
flags.Parse(args)
requirePackage(*name)
api := newClient(*server, "")
api := newClient(*server, "", *project)
latest, available, err := api.CheckUpdate(context.Background(), *name, *current)
if err != nil {
fatal("检查更新失败: %v", err)
@ -65,6 +66,7 @@ func legacyCheckCommand(args []string) {
func legacyDownloadCommand(args []string) {
flags := flag.NewFlagSet("download", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "服务端地址")
project := flags.String("project", "", "项目标识;为空时使用兼容接口")
name := flags.String("package", "", "包名")
version := flags.String("version", "", "版本;为空时下载最新版本")
output := flags.String("output", "", "保存路径")
@ -77,7 +79,7 @@ func legacyDownloadCommand(args []string) {
}
*output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, resolvedVersion))
}
api := newClient(*server, "")
api := newClient(*server, "", *project)
item, err := api.Download(context.Background(), *name, *version, *output)
if err != nil {
fatal("下载失败: %v", err)
@ -88,6 +90,7 @@ func legacyDownloadCommand(args []string) {
func legacyUploadCommand(args []string) {
flags := flag.NewFlagSet("upload", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "服务端地址")
project := flags.String("project", "", "项目标识;为空时使用兼容接口")
token := flags.String("token", "", "开发者令牌")
name := flags.String("package", "", "包名")
version := flags.String("version", "", "版本")
@ -105,7 +108,7 @@ func legacyUploadCommand(args []string) {
fatal("打开包文件失败: %v", err)
}
defer file.Close()
api := newClient(*server, *token)
api := newClient(*server, *token, *project)
item, err := api.Upload(context.Background(), client.UploadRequest{
Name: *name,
Version: *version,
@ -124,9 +127,10 @@ func legacyUploadCommand(args []string) {
func legacyWatchCommand(args []string) {
flags := flag.NewFlagSet("watch", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "服务端地址")
project := flags.String("project", "", "项目标识;为空时使用兼容接口")
packages := flags.String("packages", "", "要监听的包名,多个包用逗号分隔")
flags.Parse(args)
api := newClient(*server, "")
api := newClient(*server, "", *project)
updates, err := api.WatchUpdates(context.Background(), splitPackages(*packages)...)
if err != nil {
fatal("连接更新通知失败: %v", err)
@ -137,8 +141,12 @@ func legacyWatchCommand(args []string) {
}
}
func newClient(server, token string) *client.Client {
api, err := client.New(server, client.WithDeveloperToken(token))
func newClient(server, token string, projects ...string) *client.Client {
options := []client.Option{client.WithDeveloperToken(token)}
if len(projects) > 0 && strings.TrimSpace(projects[0]) != "" {
options = append(options, client.WithProject(projects[0]))
}
api, err := client.New(server, options...)
if err != nil {
fatal("创建客户端失败: %v", err)
}

View File

@ -23,6 +23,7 @@ type targetFlags struct {
type managerFlags struct {
server *string
project *string
packageName *string
rootDir *string
currentVersion *string
@ -38,12 +39,13 @@ type managerFlags struct {
func checkCommand(args []string) {
flags := flag.NewFlagSet("check", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740")
project := flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3")
name := flags.String("package", "", "\u5305\u540d")
current := flags.String("current", "", "\u5f53\u524d\u7248\u672c")
target := bindTargetFlags(flags)
flags.Parse(args)
requirePackage(*name)
api := newClient(*server, "")
api := newClient(*server, "", *project)
latest, available, err := api.CheckUpdateFor(context.Background(), *name, *current, target.options(*current))
if err != nil {
fatal("\u68c0\u67e5\u66f4\u65b0\u5931\u8d25: %v", err)
@ -58,6 +60,7 @@ func checkCommand(args []string) {
func downloadCommand(args []string) {
flags := flag.NewFlagSet("download", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740")
project := flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3")
name := flags.String("package", "", "\u5305\u540d")
version := flags.String("version", "", "\u7248\u672c\uff1b\u4e3a\u7a7a\u65f6\u4e0b\u8f7d\u6700\u65b0\u7248\u672c")
current := flags.String("current", "", "\u5f53\u524d\u7248\u672c")
@ -72,7 +75,7 @@ func downloadCommand(args []string) {
}
*output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, resolvedVersion))
}
api := newClient(*server, "")
api := newClient(*server, "", *project)
item, err := api.DownloadFor(context.Background(), *name, *version, *output, target.options(*current))
if err != nil {
fatal("\u4e0b\u8f7d\u5931\u8d25: %v", err)
@ -83,6 +86,7 @@ func downloadCommand(args []string) {
func uploadCommand(args []string) {
flags := flag.NewFlagSet("upload", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740")
project := flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3")
token := flags.String("token", "", "\u5f00\u53d1\u8005\u4ee4\u724c")
name := flags.String("package", "", "\u5305\u540d\uff1bScout \u5305\u53ef\u7701\u7565")
version := flags.String("version", "", "\u7248\u672c\uff1bScout \u5305\u53ef\u7701\u7565")
@ -120,7 +124,7 @@ func uploadCommand(args []string) {
fatal("\u6253\u5f00\u5305\u6587\u4ef6\u5931\u8d25: %v", err)
}
defer file.Close()
api := newClient(*server, *token)
api := newClient(*server, *token, *project)
item, err := api.Upload(context.Background(), client.UploadRequest{
Name: *name,
Version: *version,
@ -143,11 +147,12 @@ func uploadCommand(args []string) {
func watchCommand(args []string) {
flags := flag.NewFlagSet("watch", flag.ExitOnError)
server := flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740")
project := flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3")
packages := flags.String("packages", "", "\u8981\u76d1\u542c\u7684\u5305\u540d\uff0c\u591a\u4e2a\u5305\u7528\u9017\u53f7\u5206\u9694")
versions := flags.String("versions", "", "\u5f53\u524d\u7248\u672c\uff0c\u683c\u5f0f\u4e3a package:version\uff0c\u591a\u4e2a\u7528\u9017\u53f7\u5206\u9694")
target := bindTargetFlags(flags)
flags.Parse(args)
api := newClient(*server, "")
api := newClient(*server, "", *project)
updates, err := api.WatchUpdatesFor(context.Background(), client.WatchRequest{
Packages: splitPackages(*packages),
Versions: parseVersions(*versions),
@ -259,6 +264,7 @@ func bindManagerFlags(flags *flag.FlagSet) managerFlags {
defaultPlatform, defaultArchitecture := scoutpkg.CurrentTarget()
return managerFlags{
server: flags.String("server", "http://localhost:8080", "\u670d\u52a1\u7aef\u5730\u5740"),
project: flags.String("project", "", "\u9879\u76ee\u6807\u8bc6\uff1b\u4e3a\u7a7a\u65f6\u4f7f\u7528\u517c\u5bb9\u63a5\u53e3"),
packageName: flags.String("package", "", "\u5305\u540d"),
rootDir: flags.String("root", ".scout-update", "\u66f4\u65b0\u5de5\u4f5c\u76ee\u5f55"),
currentVersion: flags.String("current", "", "\u5f53\u524d\u7248\u672c"),
@ -274,7 +280,7 @@ func bindManagerFlags(flags *flag.FlagSet) managerFlags {
func newUpdateManager(input managerFlags) *updater.Manager {
requirePackage(*input.packageName)
api := newClient(*input.server, "")
api := newClient(*input.server, "", *input.project)
manager, err := updater.New(updater.Config{
Client: api,
PackageName: *input.packageName,

View File

@ -28,6 +28,10 @@ func main() {
if err != nil {
logger.Fatal(err)
}
projectStore, err := registry.NewProjectRegistry(cfg.DataDir)
if err != nil {
logger.Fatal(err)
}
userStore, err := users.New(filepath.Join(cfg.DataDir, "users.json"))
if err != nil {
logger.Fatal(err)
@ -40,6 +44,7 @@ func main() {
api := &httpapi.Server{
Registry: store,
Projects: projectStore,
DeveloperToken: cfg.DeveloperToken,
AllowLegacyToken: cfg.AllowLegacyToken,
MaxUploadBytes: cfg.MaxUploadBytes,

View File

@ -86,6 +86,7 @@ func (s *Server) upload(writer http.ResponseWriter, request *http.Request) {
}
input := registry.Package{
Project: s.projectScope,
Name: strings.TrimSpace(request.FormValue("name")),
Version: strings.TrimSpace(request.FormValue("version")),
Description: strings.TrimSpace(request.FormValue("description")),

View File

@ -0,0 +1,136 @@
package httpapi
import (
"net/http"
"net/url"
"strings"
"scout/internal/registry"
"scout/internal/users"
)
type projectCreateRequest struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
func (s *Server) projectList(writer http.ResponseWriter, _ *http.Request) {
projects := make([]registry.Project, 0, 1)
projects = append(projects, registry.Project{
ID: registry.DefaultProject,
Name: "兼容项目",
Status: "active",
})
if s.Projects != nil {
projects = append(projects, s.Projects.List()...)
}
writeJSON(writer, http.StatusOK, map[string]any{"projects": projects})
}
func (s *Server) projectCreate(writer http.ResponseWriter, request *http.Request) {
if s.Projects == nil {
writeError(writer, http.StatusServiceUnavailable, "项目管理尚未启用")
return
}
if !s.requireRole(writer, request, users.RoleAdmin) {
return
}
var input projectCreateRequest
if err := decodeJSON(request, &input); err != nil {
writeError(writer, http.StatusBadRequest, err.Error())
return
}
project, err := s.Projects.Create(registry.Project{
ID: input.ID,
Name: input.Name,
Description: input.Description,
})
if err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "已经存在") {
status = http.StatusConflict
}
writeError(writer, status, err.Error())
return
}
s.sendAdminsEmail("Scout 创建了新项目", "项目 "+project.ID+" 已创建。")
writeJSON(writer, http.StatusCreated, project)
}
func (s *Server) projectRoute(writer http.ResponseWriter, request *http.Request) {
if s.Projects == nil {
writeError(writer, http.StatusServiceUnavailable, "项目管理尚未启用")
return
}
parts := strings.Split(strings.Trim(strings.TrimPrefix(request.URL.Path, "/api/v1/projects/"), "/"), "/")
if len(parts) == 0 || parts[0] == "" {
writeError(writer, http.StatusNotFound, "项目不存在")
return
}
projectID, err := url.PathUnescape(parts[0])
if err != nil || registry.ValidateProjectID(projectID) != nil {
writeError(writer, http.StatusBadRequest, "项目标识无效")
return
}
if projectID == registry.DefaultProject {
writeError(writer, http.StatusNotFound, "default 项目请使用兼容接口")
return
}
project, exists := s.Projects.Get(projectID)
if !exists {
writeError(writer, http.StatusNotFound, "项目不存在")
return
}
if len(parts) == 1 {
if request.Method != http.MethodGet {
writeError(writer, http.StatusMethodNotAllowed, "请求方法不支持")
return
}
writeJSON(writer, http.StatusOK, project)
return
}
if project.Status != "active" {
writeError(writer, http.StatusForbidden, "项目当前已停用")
return
}
store, ok := s.Projects.Store(projectID)
if !ok {
writeError(writer, http.StatusNotFound, "项目存储不存在")
return
}
scoped := *s
scoped.Registry = store
scoped.projectScope = projectID
if parts[1] == "packages" {
if len(parts) == 2 && request.Method == http.MethodGet {
scoped.packageList(writer, scopedRequest(request, "/api/v1/packages"))
return
}
if len(parts) >= 3 && parts[2] == "upload" && request.Method == http.MethodPost {
scoped.upload(writer, scopedRequest(request, "/api/v1/packages/upload"))
return
}
if request.Method == http.MethodGet || request.Method == http.MethodHead {
rest := strings.Join(parts[1:], "/")
scoped.packageRoute(writer, scopedRequest(request, "/api/v1/"+rest))
return
}
}
if len(parts) == 2 && parts[1] == "updates" {
writeError(writer, http.StatusNotFound, "接口不存在")
return
}
if len(parts) == 3 && parts[1] == "updates" && parts[2] == "stream" && request.Method == http.MethodGet {
scoped.stream(writer, scopedRequest(request, "/api/v1/updates/stream"))
return
}
writeError(writer, http.StatusNotFound, "接口不存在")
}
func scopedRequest(request *http.Request, path string) *http.Request {
clone := request.Clone(request.Context())
clone.URL.Path = path
clone.RequestURI = ""
return clone
}

View File

@ -0,0 +1,117 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"scout/internal/registry"
)
func TestProjectRoutesKeepSamePackageNamesIsolated(t *testing.T) {
root := t.TempDir()
legacy, err := registry.New(root)
if err != nil {
t.Fatal(err)
}
projects, err := registry.NewProjectRegistry(root)
if err != nil {
t.Fatal(err)
}
if _, _, err := projects.Ensure("ssl", "SSL", "证书项目"); err != nil {
t.Fatal(err)
}
if _, _, err := projects.Ensure("smail", "Smail", "邮件项目"); err != nil {
t.Fatal(err)
}
server := httptest.NewServer((&Server{
Registry: legacy,
Projects: projects,
DeveloperToken: "test-token",
AllowLegacyToken: true,
}).Handler())
defer server.Close()
uploadProjectPackage(t, server.URL, "ssl", "shared", "1.0.0", "ssl-content")
uploadProjectPackage(t, server.URL, "smail", "shared", "1.0.0", "smail-content")
assertProjectPackageContent(t, server.URL+"/api/v1/projects/ssl/packages/shared/1.0.0/download?platform=linux&architecture=amd64", "ssl-content")
assertProjectPackageContent(t, server.URL+"/api/v1/projects/smail/packages/shared/1.0.0/download?platform=linux&architecture=amd64", "smail-content")
response, err := http.Get(server.URL + "/api/v1/projects")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
var listed struct {
Projects []registry.Project `json:"projects"`
}
if err := json.NewDecoder(response.Body).Decode(&listed); err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || len(listed.Projects) != 3 {
t.Fatalf("项目列表错误:%d %+v", response.StatusCode, listed.Projects)
}
}
func uploadProjectPackage(t *testing.T, baseURL, project, name, version, content string) {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for key, value := range map[string]string{
"name": name,
"version": version,
"platform": "linux",
"architecture": "amd64",
"status": "published",
} {
if err := writer.WriteField(key, value); err != nil {
t.Fatal(err)
}
}
file, err := writer.CreateFormFile("file", name+"-"+version+".pkg")
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte(content)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
request, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/projects/"+project+"/packages/upload", body)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Content-Type", writer.FormDataContentType())
request.Header.Set("Authorization", "Bearer test-token")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
data, _ := io.ReadAll(response.Body)
t.Fatalf("项目上传失败:%d %s", response.StatusCode, data)
}
}
func assertProjectPackageContent(t *testing.T, endpoint, expected string) {
t.Helper()
response, err := http.Get(endpoint)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || string(data) != expected {
t.Fatalf("项目包内容错误:%d %q", response.StatusCode, data)
}
}

View File

@ -22,6 +22,7 @@ import (
type Server struct {
Registry *registry.Registry
Projects *registry.ProjectRegistry
DeveloperToken string
MaxUploadBytes int64
Users *users.Store
@ -36,6 +37,7 @@ type Server struct {
Logger *log.Logger
resetMu sync.Mutex
resetTokens map[string]resetToken
projectScope string
}
type resetToken struct {
@ -69,6 +71,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/v1/auth/me", s.me)
mux.HandleFunc("POST /api/v1/auth/password/request", s.requestPasswordReset)
mux.HandleFunc("POST /api/v1/auth/password/reset", s.resetPassword)
mux.HandleFunc("GET /api/v1/projects", s.projectList)
mux.HandleFunc("POST /api/v1/projects", s.projectCreate)
mux.HandleFunc("/api/v1/projects/", s.projectRoute)
mux.HandleFunc("GET /api/v1/admin/users", s.adminUsers)
mux.HandleFunc("PATCH /api/v1/admin/users/", s.adminUpdateUser)
mux.HandleFunc("POST /api/v1/packages/upload", s.upload)

View File

@ -0,0 +1,200 @@
package registry
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const DefaultProject = "default"
var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProjectRegistry struct {
root string
projects map[string]Project
registries map[string]*Registry
mu sync.RWMutex
}
func ValidateProjectID(id string) error {
if !projectIDPattern.MatchString(strings.TrimSpace(id)) {
return errors.New("项目标识只能包含字母、数字、点、下划线和短横线,长度为 1 到 64")
}
return nil
}
func NewProjectRegistry(root string) (*ProjectRegistry, error) {
if strings.TrimSpace(root) == "" {
return nil, errors.New("项目存储目录不能为空")
}
projectsRoot := filepath.Join(root, "projects")
if err := os.MkdirAll(projectsRoot, 0o755); err != nil {
return nil, fmt.Errorf("创建项目存储目录失败: %w", err)
}
result := &ProjectRegistry{
root: projectsRoot,
projects: make(map[string]Project),
registries: make(map[string]*Registry),
}
entries, err := os.ReadDir(projectsRoot)
if err != nil {
return nil, fmt.Errorf("读取项目目录失败: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() || ValidateProjectID(entry.Name()) != nil {
continue
}
if err := result.loadProject(entry.Name()); err != nil {
return nil, err
}
}
return result, nil
}
func (r *ProjectRegistry) Create(input Project) (Project, error) {
input.ID = strings.TrimSpace(input.ID)
if err := ValidateProjectID(input.ID); err != nil {
return Project{}, err
}
if input.ID == DefaultProject {
return Project{}, errors.New("default 是兼容项目标识,不能创建")
}
if input.Name == "" {
input.Name = input.ID
}
input.Status = strings.TrimSpace(input.Status)
if input.Status == "" {
input.Status = "active"
}
if input.Status != "active" && input.Status != "disabled" {
return Project{}, errors.New("项目状态只能是 active 或 disabled")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.projects[input.ID]; exists {
return Project{}, errors.New("项目已经存在")
}
now := time.Now().UTC()
input.CreatedAt = now
input.UpdatedAt = now
projectDir := filepath.Join(r.root, input.ID)
if err := os.MkdirAll(projectDir, 0o755); err != nil {
return Project{}, fmt.Errorf("创建项目目录失败: %w", err)
}
if err := writeProjectMetadata(projectDir, input); err != nil {
return Project{}, err
}
store, err := New(projectDir)
if err != nil {
return Project{}, err
}
r.projects[input.ID] = input
r.registries[input.ID] = store
return input, nil
}
func (r *ProjectRegistry) Ensure(id, name, description string) (Project, *Registry, error) {
id = strings.TrimSpace(id)
if err := ValidateProjectID(id); err != nil {
return Project{}, nil, err
}
if id == DefaultProject {
return Project{}, nil, errors.New("default 是兼容项目标识,请继续使用旧接口")
}
r.mu.RLock()
project, exists := r.projects[id]
store := r.registries[id]
r.mu.RUnlock()
if exists {
return project, store, nil
}
project, err := r.Create(Project{ID: id, Name: name, Description: description})
if err != nil {
return Project{}, nil, err
}
r.mu.RLock()
store = r.registries[id]
r.mu.RUnlock()
return project, store, nil
}
func (r *ProjectRegistry) Get(id string) (Project, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
project, ok := r.projects[strings.TrimSpace(id)]
return project, ok
}
func (r *ProjectRegistry) List() []Project {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]Project, 0, len(r.projects))
for _, project := range r.projects {
result = append(result, project)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (r *ProjectRegistry) Store(id string) (*Registry, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
store, ok := r.registries[strings.TrimSpace(id)]
return store, ok
}
func (r *ProjectRegistry) loadProject(id string) error {
projectDir := filepath.Join(r.root, id)
project := Project{ID: id, Name: id, Status: "active"}
metadata, err := os.ReadFile(filepath.Join(projectDir, "project.json"))
if err == nil {
if err := json.Unmarshal(metadata, &project); err != nil {
return fmt.Errorf("读取项目元数据失败: %w", err)
}
project.ID = id
if project.Name == "" {
project.Name = id
}
if project.Status == "" {
project.Status = "active"
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("读取项目元数据失败: %w", err)
}
store, err := New(projectDir)
if err != nil {
return fmt.Errorf("加载项目 %s 失败: %w", id, err)
}
r.projects[id] = project
r.registries[id] = store
return nil
}
func writeProjectMetadata(projectDir string, project Project) error {
data, err := json.MarshalIndent(project, "", " ")
if err != nil {
return fmt.Errorf("编码项目元数据失败: %w", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "project.json"), data, 0o644); err != nil {
return fmt.Errorf("保存项目元数据失败: %w", err)
}
return nil
}

View File

@ -0,0 +1,79 @@
package registry
import (
"strings"
"testing"
)
func TestProjectRegistryKeepsProjectsIsolated(t *testing.T) {
root := t.TempDir()
projects, err := NewProjectRegistry(root)
if err != nil {
t.Fatal(err)
}
_, ssl, err := projects.Ensure("ssl", "SSL", "证书项目")
if err != nil {
t.Fatal(err)
}
_, smail, err := projects.Ensure("smail", "Smail", "邮件项目")
if err != nil {
t.Fatal(err)
}
if _, err := ssl.AddPackage(Package{
Name: "shared",
Version: "1.0.0",
Platform: "linux",
Architecture: "amd64",
Status: "published",
}, strings.NewReader("ssl")); err != nil {
t.Fatal(err)
}
if _, err := smail.AddPackage(Package{
Name: "shared",
Version: "1.0.0",
Platform: "linux",
Architecture: "amd64",
Status: "published",
}, strings.NewReader("smail")); err != nil {
t.Fatal(err)
}
sslItem, ok := ssl.Latest("shared")
if !ok || sslItem.SHA256 == "" {
t.Fatal("SSL 项目未保存包")
}
smailItem, ok := smail.Latest("shared")
if !ok || smailItem.SHA256 == "" {
t.Fatal("Smail 项目未保存包")
}
if sslItem.SHA256 == smailItem.SHA256 {
t.Fatal("不同项目不应共享同一个包内容")
}
if len(projects.List()) != 2 {
t.Fatalf("项目数量错误:%d", len(projects.List()))
}
reloaded, err := NewProjectRegistry(root)
if err != nil {
t.Fatal(err)
}
store, ok := reloaded.Store("ssl")
if !ok {
t.Fatal("重载后找不到 SSL 项目")
}
if item, ok := store.Latest("shared"); !ok || item.SHA256 == "" {
t.Fatalf("项目包重载结果异常:%+v", item)
}
}
func TestValidateProjectID(t *testing.T) {
for _, value := range []string{"ssl", "Smail_2", "release.center"} {
if err := ValidateProjectID(value); err != nil {
t.Errorf("项目标识 %q 不应失败:%v", value, err)
}
}
for _, value := range []string{"", "../ssl", "项目", "a/b"} {
if err := ValidateProjectID(value); err == nil {
t.Errorf("项目标识 %q 应该失败", value)
}
}
}

View File

@ -23,6 +23,7 @@ var (
)
type Package struct {
Project string `json:"project,omitempty"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`

View File

@ -19,6 +19,7 @@ import (
)
type Package struct {
Project string `json:"project,omitempty"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
@ -71,6 +72,7 @@ type Client struct {
baseURL string
httpClient *http.Client
token string
project string
}
func New(baseURL string, options ...Option) (*Client, error) {
@ -102,11 +104,42 @@ func WithDeveloperToken(token string) Option {
}
}
func WithProject(project string) Option {
return func(client *Client) {
client.project = strings.TrimSpace(project)
}
}
func NewProject(baseURL, project string, options ...Option) (*Client, error) {
project = strings.TrimSpace(project)
if project == "" {
return nil, errors.New("\u9879\u76ee\u6807\u8bc6\u4e0d\u80fd\u4e3a\u7a7a")
}
for _, char := range project {
if !(char == '-' || char == '_' || char == '.' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9') {
return nil, errors.New("\u9879\u76ee\u6807\u8bc6\u5305\u542b\u65e0\u6548\u5b57\u7b26")
}
}
options = append(options, WithProject(project))
return New(baseURL, options...)
}
func (c *Client) Project() string {
return c.project
}
func (c *Client) resourcePath(path string) string {
if c.project == "" {
return path
}
return "/api/v1/projects/" + url.PathEscape(c.project) + path[len("/api/v1"):]
}
func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
var response struct {
Packages []Package `json:"packages"`
}
if err := c.getJSON(ctx, "/api/v1/packages", &response); err != nil {
if err := c.getJSON(ctx, c.resourcePath("/api/v1/packages"), &response); err != nil {
return nil, err
}
return response.Packages, nil
@ -114,7 +147,7 @@ func (c *Client) ListPackages(ctx context.Context) ([]Package, error) {
func (c *Client) GetLatest(ctx context.Context, name string) (Package, error) {
var item Package
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name), &item); err != nil {
if err := c.getJSON(ctx, c.resourcePath("/api/v1/packages/")+url.PathEscape(name), &item); err != nil {
return Package{}, err
}
return item, nil
@ -125,7 +158,7 @@ func (c *Client) ListVersions(ctx context.Context, name string) ([]Package, erro
Name string `json:"name"`
Versions []Package `json:"versions"`
}
if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name)+"/versions", &response); err != nil {
if err := c.getJSON(ctx, c.resourcePath("/api/v1/packages/")+url.PathEscape(name)+"/versions", &response); err != nil {
return nil, err
}
return response.Versions, nil
@ -147,7 +180,7 @@ func (c *Client) Download(ctx context.Context, name, version, destination string
}
version = latest.Version
}
path := "/api/v1/packages/" + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return Package{}, err
@ -208,7 +241,7 @@ func (c *Client) Upload(ctx context.Context, input UploadRequest) (Package, erro
}
_ = bodyWriter.CloseWithError(err)
}()
request, err := c.newRequest(ctx, http.MethodPost, "/api/v1/packages/upload", bodyReader)
request, err := c.newRequest(ctx, http.MethodPost, c.resourcePath("/api/v1/packages/upload"), bodyReader)
if err != nil {
return Package{}, err
}
@ -240,7 +273,7 @@ func (c *Client) WatchUpdates(ctx context.Context, packages ...string) (<-chan U
if len(wanted) > 0 {
query.Set("packages", strings.Join(wanted, ","))
}
path := "/api/v1/updates/stream"
path := c.resourcePath("/api/v1/updates/stream")
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}

View File

@ -34,7 +34,7 @@ func (c *Client) ListPackagesFor(ctx context.Context, options QueryOptions) ([]P
var response struct {
Packages []Package `json:"packages"`
}
if err := c.getJSON(ctx, withQuery("/api/v1/packages", options.values()), &response); err != nil {
if err := c.getJSON(ctx, withQuery(c.resourcePath("/api/v1/packages"), options.values()), &response); err != nil {
return nil, err
}
return response.Packages, nil
@ -42,7 +42,7 @@ func (c *Client) ListPackagesFor(ctx context.Context, options QueryOptions) ([]P
func (c *Client) GetLatestFor(ctx context.Context, name string, options QueryOptions) (Package, error) {
var item Package
path := "/api/v1/packages/" + url.PathEscape(name)
path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name)
if err := c.getJSON(ctx, withQuery(path, options.values()), &item); err != nil {
return Package{}, err
}
@ -81,7 +81,7 @@ func (c *Client) DownloadFor(ctx context.Context, name, version, destination str
}
version = latest.Version
}
path := "/api/v1/packages/" + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
path := c.resourcePath("/api/v1/packages/") + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download"
request, err := c.newRequest(ctx, http.MethodGet, withQuery(path, options.values()), nil)
if err != nil {
return Package{}, err
@ -165,7 +165,7 @@ func (c *Client) WatchUpdatesFor(ctx context.Context, input WatchRequest) (<-cha
if input.Channel != "" {
query.Set("channel", input.Channel)
}
path := withQuery("/api/v1/updates/stream", query)
path := withQuery(c.resourcePath("/api/v1/updates/stream"), query)
request, err := c.newRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err

66
pkg/client/projects.go Normal file
View File

@ -0,0 +1,66 @@
package client
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
)
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
var response struct {
Projects []Project `json:"projects"`
}
if err := c.getJSON(ctx, "/api/v1/projects", &response); err != nil {
return nil, err
}
return response.Projects, nil
}
func (c *Client) GetProject(ctx context.Context, project string) (Project, error) {
project = strings.TrimSpace(project)
if project == "" {
return Project{}, errors.New("项目标识不能为空")
}
var result Project
path := "/api/v1/projects/" + url.PathEscape(project)
if err := c.getJSON(ctx, path, &result); err != nil {
return Project{}, err
}
return result, nil
}
func (c *Client) CreateProject(ctx context.Context, project Project) (Project, error) {
var result Project
if err := c.postJSON(ctx, "/api/v1/projects", map[string]string{
"id": project.ID,
"name": project.Name,
"description": project.Description,
}, &result); err != nil {
return Project{}, err
}
return result, nil
}
func (c *Client) ProjectHealth(ctx context.Context) error {
request, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil)
if err != nil {
return err
}
response, err := c.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
return checkResponse(response)
}

View File

@ -0,0 +1,37 @@
package client
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestProjectClientUsesProjectScopedPaths(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/v1/projects/ssl/packages/demo" {
t.Fatalf("项目请求路径错误:%s", request.URL.Path)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"name":"demo","version":"1.0.0","project":"ssl"}`))
}))
defer server.Close()
api, err := NewProject(server.URL, "ssl")
if err != nil {
t.Fatal(err)
}
item, err := api.GetLatest(context.Background(), "demo")
if err != nil {
t.Fatal(err)
}
if item.Project != "ssl" || item.Version != "1.0.0" {
t.Fatalf("项目包响应错误:%+v", item)
}
}
func TestNewProjectRejectsUnsafeProjectID(t *testing.T) {
if _, err := NewProject("http://127.0.0.1", "../ssl"); err == nil {
t.Fatal("不安全项目标识应该失败")
}
}