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) }) }