Scout/internal/httpapi/server.go
2026-08-30 21:50:03 +08:00

197 lines
5.9 KiB
Go

package httpapi
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"scout/internal/registry"
)
type Server struct {
Registry *registry.Registry
DeveloperToken string
MaxUploadBytes int64
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("POST /api/v1/packages/upload", s.upload)
mux.HandleFunc("GET /api/v1/updates/stream", s.stream)
mux.HandleFunc("GET /api/v1/packages", s.packageList)
mux.HandleFunc("GET /api/v1/packages/", s.packageRoute)
return cors(logging(mux))
}
func (s *Server) health(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) packageList(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, map[string]any{"packages": s.Registry.List()})
}
func (s *Server) upload(writer http.ResponseWriter, request *http.Request) {
if !s.authorized(request) {
writeError(writer, http.StatusUnauthorized, "开发者令牌无效")
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 {
writeError(writer, http.StatusBadRequest, "上传表单无效: "+err.Error())
return
}
file, _, err := request.FormFile("file")
if err != nil {
writeError(writer, http.StatusBadRequest, "缺少包文件字段 file")
return
}
defer file.Close()
item, err := s.Registry.AddPackage(registry.Package{
Name: request.FormValue("name"),
Version: request.FormValue("version"),
Description: request.FormValue("description"),
Platform: request.FormValue("platform"),
Architecture: request.FormValue("architecture"),
}, file)
if err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "已经存在") {
status = http.StatusConflict
}
writeError(writer, status, err.Error())
return
}
writeJSON(writer, http.StatusCreated, item)
}
func (s *Server) packageRoute(writer http.ResponseWriter, request *http.Request) {
path := strings.TrimPrefix(request.URL.Path, "/api/v1/packages/")
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 1 && parts[0] != "" {
item, ok := s.Registry.Latest(parts[0])
if !ok {
writeError(writer, http.StatusNotFound, "包不存在")
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.Versions(parts[0])})
return
}
writeError(writer, http.StatusNotFound, "接口不存在")
return
}
if len(parts) == 3 && parts[2] == "download" {
s.download(writer, request, parts[0], parts[1])
return
}
writeError(writer, http.StatusNotFound, "接口不存在")
}
func (s *Server) download(writer http.ResponseWriter, request *http.Request, name, version string) {
file, item, err := s.Registry.OpenPackage(name, version)
if err != nil {
writeError(writer, http.StatusNotFound, "包版本不存在")
return
}
defer file.Close()
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%s.pkg"`, name, version))
writer.Header().Set("X-Scout-SHA256", item.SHA256)
writer.Header().Set("Content-Length", strconv.FormatInt(item.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, "当前服务器不支持实时通知")
return
}
wanted := make(map[string]bool)
for _, name := range strings.Split(request.URL.Query().Get("packages"), ",") {
if name = strings.TrimSpace(name); name != "" {
wanted[name] = true
}
}
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, ": 已连接\n\n")
flusher.Flush()
for {
select {
case <-request.Context().Done():
return
case update, ok := <-updates:
if !ok {
return
}
if len(wanted) > 0 && !wanted[update.Package.Name] {
continue
}
payload, _ := json.Marshal(update)
fmt.Fprintf(writer, "event: package_updated\ndata: %s\n\n", payload)
flusher.Flush()
}
}
}
func (s *Server) authorized(request *http.Request) bool {
if s.DeveloperToken == "" {
return false
}
value := request.Header.Get("Authorization")
if strings.HasPrefix(value, "Bearer ") {
return strings.TrimPrefix(value, "Bearer ") == s.DeveloperToken
}
return request.Header.Get("X-Developer-Token") == s.DeveloperToken
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func writeError(writer http.ResponseWriter, status int, message string) {
writeJSON(writer, status, map[string]string{"error": message})
}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Access-Control-Allow-Origin", "*")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token")
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
next.ServeHTTP(writer, request)
})
}