Scout/internal/httpapi/packages.go

328 lines
11 KiB
Go

package httpapi
import (
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"scout/internal/registry"
"scout/internal/users"
"scout/pkg/scoutpkg"
)
func (s *Server) packageList(writer http.ResponseWriter, request *http.Request) {
queryValues := request.URL.Query()
items := s.Registry.ListFor(
queryValues.Get("platform"),
queryValues.Get("architecture"),
queryValues.Get("channel"),
queryValues.Get("current_version"),
)
query := strings.ToLower(strings.TrimSpace(queryValues.Get("query")))
if query != "" {
filtered := make([]registry.Package, 0, len(items))
for _, item := range items {
if strings.Contains(strings.ToLower(item.Name), query) || strings.Contains(strings.ToLower(item.Description), query) {
filtered = append(filtered, item)
}
}
items = filtered
}
total := len(items)
page := positiveInt(queryValues.Get("page"), 1)
pageSize := positiveInt(queryValues.Get("page_size"), 50)
if pageSize > 100 {
pageSize = 100
}
start := (page - 1) * pageSize
if start > len(items) {
start = len(items)
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
writeJSON(writer, http.StatusOK, map[string]any{
"packages": items[start:end],
"total": total,
"page": page,
"page_size": pageSize,
})
}
func (s *Server) upload(writer http.ResponseWriter, request *http.Request) {
uploader, authorized := s.uploadUser(request)
if !authorized {
if _, loggedIn := s.authenticatedUser(request); loggedIn {
writeError(writer, http.StatusForbidden, "\u9700\u8981\u5f00\u53d1\u8005\u6216\u7ba1\u7406\u5458\u6743\u9650")
} else {
writeError(writer, http.StatusUnauthorized, "\u8bf7\u5148\u767b\u5f55\u5f00\u53d1\u8005\u8d26\u6237")
}
return
}
maxBytes := s.MaxUploadBytes
if maxBytes <= 0 {
maxBytes = 512 << 20
}
request.Body = http.MaxBytesReader(writer, request.Body, maxBytes)
if err := request.ParseMultipartForm(32 << 20); err != nil {
s.uploadError(writer, uploader, http.StatusBadRequest, "\u4e0a\u4f20\u8868\u5355\u65e0\u6548: "+err.Error())
return
}
file, header, err := request.FormFile("file")
if err != nil {
s.uploadError(writer, uploader, http.StatusBadRequest, "\u7f3a\u5c11\u5305\u6587\u4ef6\u5b57\u6bb5 file")
return
}
defer file.Close()
fileName := filepath.Base(header.Filename)
if err := registry.ValidateFileName(fileName); err != nil {
s.uploadError(writer, uploader, http.StatusBadRequest, err.Error())
return
}
input := registry.Package{
Project: s.projectScope,
Name: strings.TrimSpace(request.FormValue("name")),
Version: strings.TrimSpace(request.FormValue("version")),
Description: strings.TrimSpace(request.FormValue("description")),
PackageType: strings.TrimSpace(request.FormValue("package_type")),
Channel: strings.TrimSpace(request.FormValue("channel")),
Status: strings.TrimSpace(request.FormValue("status")),
FileName: fileName,
UploaderID: uploader.ID,
UploaderEmail: uploader.Email,
}
var item registry.Package
if strings.EqualFold(filepath.Ext(fileName), ".scout") {
item, err = s.uploadScoutPackage(file, header.Size, input)
} else {
platform := strings.TrimSpace(request.FormValue("platform"))
architecture := strings.TrimSpace(request.FormValue("architecture"))
if input.Name == "" || input.Version == "" || platform == "" || architecture == "" {
s.uploadError(writer, uploader, http.StatusBadRequest, "\u666e\u901a\u5305\u5fc5\u987b\u6307\u5b9a\u5305\u540d\u3001\u7248\u672c\u3001\u5e73\u53f0\u548c\u67b6\u6784")
return
}
target := registry.Target{
Platform: platform,
Architecture: architecture,
PackageType: input.PackageType,
FileName: fileName,
UpgradeFrom: splitValues(request.FormValue("upgrade_from")),
}
item, err = s.Registry.AddArtifact(input, target, file)
}
if err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "\u5df2\u7ecf\u5b58\u5728") {
status = http.StatusConflict
}
s.uploadError(writer, uploader, status, err.Error())
return
}
s.sendUserEmail(uploader, "Scout \u5305\u4e0a\u4f20\u6210\u529f", fmt.Sprintf("\u5305 %s %s \u5df2\u4e0a\u4f20\u6210\u529f\u3002\n\nSHA-256\uff1a%s\n\u5927\u5c0f\uff1a%d \u5b57\u8282", item.Name, item.Version, item.SHA256, item.Size))
writeJSON(writer, http.StatusCreated, item)
}
func (s *Server) uploadScoutPackage(file interface {
io.Reader
io.ReaderAt
io.Seeker
}, size int64, input registry.Package) (registry.Package, error) {
manifest, err := scoutpkg.Inspect(file, size)
if err != nil {
return registry.Package{}, err
}
if input.Name != "" && input.Name != manifest.Name {
return registry.Package{}, fmt.Errorf("\u8868\u5355\u5305\u540d\u4e0e Scout \u6e05\u5355\u4e0d\u4e00\u81f4")
}
if input.Version != "" && input.Version != manifest.Version {
return registry.Package{}, fmt.Errorf("\u8868\u5355\u7248\u672c\u4e0e Scout \u6e05\u5355\u4e0d\u4e00\u81f4")
}
if input.Channel != "" && manifest.Channel != "" && input.Channel != manifest.Channel {
return registry.Package{}, fmt.Errorf("\u8868\u5355\u53d1\u5e03\u901a\u9053\u4e0e Scout \u6e05\u5355\u4e0d\u4e00\u81f4")
}
if input.PackageType != "" && manifest.PackageType != "" && input.PackageType != manifest.PackageType {
return registry.Package{}, fmt.Errorf("\u8868\u5355\u5305\u7c7b\u578b\u4e0e Scout \u6e05\u5355\u4e0d\u4e00\u81f4")
}
input.Name = manifest.Name
input.Version = manifest.Version
if input.Description == "" {
input.Description = manifest.Description
}
if input.Channel == "" {
input.Channel = manifest.Channel
}
if input.PackageType == "" {
input.PackageType = manifest.PackageType
}
targets := make([]registry.Target, 0, len(manifest.Targets))
for _, target := range manifest.Targets {
targets = append(targets, registry.Target{
ID: target.ID,
Platform: target.Platform,
Architecture: target.Architecture,
PackageType: target.PackageType,
FileName: input.FileName,
Entrypoint: target.Entrypoint,
UpgradeFrom: append([]string(nil), target.UpgradeFrom...),
})
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return registry.Package{}, fmt.Errorf("\u91cd\u7f6e Scout \u5305\u8bfb\u53d6\u4f4d\u7f6e\u5931\u8d25: %w", err)
}
return s.Registry.AddScoutPackage(input, targets, file)
}
func (s *Server) uploadError(writer http.ResponseWriter, uploader users.User, status int, message string) {
s.notifyUploadFailure(uploader, message)
writeError(writer, status, message)
}
func (s *Server) packageRoute(writer http.ResponseWriter, request *http.Request) {
pathValue := strings.TrimPrefix(request.URL.Path, "/api/v1/packages/")
parts := strings.Split(strings.Trim(pathValue, "/"), "/")
query := request.URL.Query()
platform := query.Get("platform")
architecture := query.Get("architecture")
channel := query.Get("channel")
currentVersion := query.Get("current_version")
if len(parts) == 1 && parts[0] != "" {
item, ok := s.Registry.LatestFor(parts[0], platform, architecture, channel, currentVersion)
if !ok {
writeError(writer, http.StatusNotFound, "\u6ca1\u6709\u627e\u5230\u517c\u5bb9\u7684\u5305")
return
}
writeJSON(writer, http.StatusOK, item)
return
}
if len(parts) == 2 && parts[0] != "" {
if parts[1] == "versions" {
writeJSON(writer, http.StatusOK, map[string]any{
"name": parts[0],
"versions": s.Registry.VersionsFor(
parts[0], platform, architecture, channel, currentVersion,
),
})
return
}
writeError(writer, http.StatusNotFound, "\u63a5\u53e3\u4e0d\u5b58\u5728")
return
}
if len(parts) == 3 && parts[2] == "download" {
s.download(writer, request, parts[0], parts[1])
return
}
writeError(writer, http.StatusNotFound, "\u63a5\u53e3\u4e0d\u5b58\u5728")
}
func (s *Server) download(writer http.ResponseWriter, request *http.Request, name, version string) {
query := request.URL.Query()
file, item, err := s.Registry.OpenPackageFor(
name,
version,
query.Get("platform"),
query.Get("architecture"),
query.Get("current_version"),
)
if err != nil || len(item.Targets) == 0 {
writeError(writer, http.StatusNotFound, "\u5305\u7248\u672c\u4e0d\u5b58\u5728\u6216\u4e0d\u517c\u5bb9")
return
}
defer file.Close()
target := item.Targets[0]
fileName := filepath.Base(strings.TrimSpace(target.FileName))
if err := registry.ValidateFileName(fileName); err != nil {
fileName = fmt.Sprintf("%s-%s.pkg", name, version)
}
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
writer.Header().Set("X-Scout-SHA256", target.SHA256)
writer.Header().Set("X-Scout-Platform", target.Platform)
writer.Header().Set("X-Scout-Architecture", target.Architecture)
writer.Header().Set("X-Scout-Package-Type", target.PackageType)
writer.Header().Set("Content-Length", strconv.FormatInt(target.Size, 10))
if request.Method == http.MethodHead {
return
}
_, _ = io.Copy(writer, file)
}
func (s *Server) stream(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
writeError(writer, http.StatusInternalServerError, "\u5f53\u524d\u670d\u52a1\u5668\u4e0d\u652f\u6301\u5b9e\u65f6\u901a\u77e5")
return
}
query := request.URL.Query()
wanted := make(map[string]bool)
for _, name := range splitValues(query.Get("packages")) {
wanted[name] = true
}
versions := parseVersionMap(query.Get("versions"))
platform := query.Get("platform")
architecture := query.Get("architecture")
channel := strings.ToLower(strings.TrimSpace(query.Get("channel")))
updates, unsubscribe := s.Registry.Subscribe()
defer unsubscribe()
writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
fmt.Fprint(writer, ": connected\n\n")
flusher.Flush()
for {
select {
case <-request.Context().Done():
return
case update, open := <-updates:
if !open {
return
}
if len(wanted) > 0 && !wanted[update.Package.Name] {
continue
}
if channel != "" && update.Package.Channel != channel {
continue
}
currentVersion := versions[update.Package.Name]
if currentVersion != "" && registry.CompareVersions(update.Package.Version, currentVersion) <= 0 {
continue
}
selected, compatible := registry.SelectTarget(update.Package, platform, architecture, currentVersion)
if !compatible {
continue
}
payload, _ := json.Marshal(registry.Update{Package: selected})
fmt.Fprintf(writer, "event: package_updated\ndata: %s\n\n", payload)
flusher.Flush()
}
}
}
func splitValues(value string) []string {
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
result = append(result, part)
}
}
return result
}
func parseVersionMap(value string) map[string]string {
result := make(map[string]string)
for _, entry := range splitValues(value) {
parts := strings.SplitN(entry, ":", 2)
if len(parts) == 2 && strings.TrimSpace(parts[0]) != "" {
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
return result
}