diff --git a/cmd/scout-client/main.go b/cmd/scout-client/main.go index e75f27e..e4d4ec7 100644 --- a/cmd/scout-client/main.go +++ b/cmd/scout-client/main.go @@ -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) } diff --git a/cmd/scout-client/update_commands.go b/cmd/scout-client/update_commands.go index a1b2f56..47f3892 100644 --- a/cmd/scout-client/update_commands.go +++ b/cmd/scout-client/update_commands.go @@ -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, diff --git a/cmd/scout-server/main.go b/cmd/scout-server/main.go index ceb3c60..1030a02 100644 --- a/cmd/scout-server/main.go +++ b/cmd/scout-server/main.go @@ -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, diff --git a/internal/httpapi/packages.go b/internal/httpapi/packages.go index 86680dc..f3942eb 100644 --- a/internal/httpapi/packages.go +++ b/internal/httpapi/packages.go @@ -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")), diff --git a/internal/httpapi/projects.go b/internal/httpapi/projects.go new file mode 100644 index 0000000..ee2d2d7 --- /dev/null +++ b/internal/httpapi/projects.go @@ -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 +} diff --git a/internal/httpapi/projects_test.go b/internal/httpapi/projects_test.go new file mode 100644 index 0000000..0173fc1 --- /dev/null +++ b/internal/httpapi/projects_test.go @@ -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) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 2555c55..fda9dce 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) diff --git a/internal/registry/projects.go b/internal/registry/projects.go new file mode 100644 index 0000000..99602b5 --- /dev/null +++ b/internal/registry/projects.go @@ -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 +} diff --git a/internal/registry/projects_test.go b/internal/registry/projects_test.go new file mode 100644 index 0000000..5c4c5cf --- /dev/null +++ b/internal/registry/projects_test.go @@ -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) + } + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index b5312d3..235248c 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -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"` diff --git a/pkg/client/client.go b/pkg/client/client.go index 4b3f34a..725c2d5 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -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 } diff --git a/pkg/client/platform.go b/pkg/client/platform.go index cb29d12..1d15505 100644 --- a/pkg/client/platform.go +++ b/pkg/client/platform.go @@ -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 diff --git a/pkg/client/projects.go b/pkg/client/projects.go new file mode 100644 index 0000000..de43ab9 --- /dev/null +++ b/pkg/client/projects.go @@ -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) +} diff --git a/pkg/client/projects_test.go b/pkg/client/projects_test.go new file mode 100644 index 0000000..1843bb6 --- /dev/null +++ b/pkg/client/projects_test.go @@ -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("不安全项目标识应该失败") + } +}