package httpapi import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net/http" "strconv" "strings" "sync" "time" "scout/internal/notify" "scout/internal/registry" "scout/internal/users" ) type Server struct { Registry *registry.Registry DeveloperToken string MaxUploadBytes int64 Users *users.Store Mailer notify.Sender SessionDuration time.Duration AllowedOrigins []string AllowedHosts []string ForceHTTPS bool TrustProxy bool AllowLegacyToken bool RateLimitPerMinute int Logger *log.Logger resetMu sync.Mutex resetTokens map[string]resetToken } type resetToken struct { UserID string ExpiresAt time.Time } type credentials struct { Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` } type updateUserRequest struct { Role users.Role `json:"role"` Active bool `json:"active"` } type userResponse struct { User users.User `json:"user"` Session string `json:"session,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"` } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.health) mux.HandleFunc("POST /api/v1/auth/register", s.register) mux.HandleFunc("POST /api/v1/auth/login", s.login) mux.HandleFunc("POST /api/v1/auth/logout", s.logout) 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/admin/users", s.adminUsers) mux.HandleFunc("PATCH /api/v1/admin/users/", s.adminUpdateUser) 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 securityHeaders(s.hostGuard(s.rateLimit(s.cors(logging(mux))))) } func (s *Server) health(writer http.ResponseWriter, _ *http.Request) { writeJSON(writer, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) register(writer http.ResponseWriter, request *http.Request) { if s.Users == nil { writeError(writer, http.StatusServiceUnavailable, "用户管理尚未启用") return } var input credentials if err := decodeJSON(request, &input); err != nil { writeError(writer, http.StatusBadRequest, err.Error()) return } user, err := s.Users.Register(input.Email, input.Name, input.Password) if err != nil { writeError(writer, http.StatusBadRequest, err.Error()) return } s.sendUserEmail(user, "Scout 账户已创建", fmt.Sprintf("你好,%s。你的 Scout 账户已创建成功。\n\n如果这不是你的操作,请立即联系管理员。", user.Name)) s.sendAdminsEmail("Scout 有新用户注册", fmt.Sprintf("新用户 %s(%s)已注册 Scout 账户。", user.Name, user.Email)) writeJSON(writer, http.StatusCreated, map[string]any{"user": user}) } func (s *Server) login(writer http.ResponseWriter, request *http.Request) { if s.Users == nil { writeError(writer, http.StatusServiceUnavailable, "用户管理尚未启用") return } var input credentials if err := decodeJSON(request, &input); err != nil { writeError(writer, http.StatusBadRequest, err.Error()) return } user, err := s.Users.Authenticate(input.Email, input.Password) if err != nil { writeError(writer, http.StatusUnauthorized, err.Error()) return } duration := s.SessionDuration if duration <= 0 { duration = 24 * time.Hour } session, expiresAt, err := s.Users.CreateSession(user.ID, duration) if err != nil { writeError(writer, http.StatusInternalServerError, err.Error()) return } s.sendUserEmail(user, "Scout 登录安全提醒", fmt.Sprintf("你的 Scout 账户于 %s 登录。\n\n如果这不是你的操作,请立即修改密码。", time.Now().Format("2006-01-02 15:04:05"))) writeJSON(writer, http.StatusOK, userResponse{User: user, Session: session, ExpiresAt: expiresAt}) } func (s *Server) logout(writer http.ResponseWriter, request *http.Request) { if s.Users != nil { s.Users.RevokeSession(bearerToken(request)) } writeJSON(writer, http.StatusOK, map[string]string{"message": "已退出登录"}) } func (s *Server) me(writer http.ResponseWriter, request *http.Request) { user, ok := s.authenticatedUser(request) if !ok { writeError(writer, http.StatusUnauthorized, "请先登录") return } writeJSON(writer, http.StatusOK, map[string]any{"user": user}) } func (s *Server) requestPasswordReset(writer http.ResponseWriter, request *http.Request) { var input struct { Email string `json:"email"` } if err := decodeJSON(request, &input); err != nil { writeError(writer, http.StatusBadRequest, err.Error()) return } response := map[string]string{"message": "如果邮箱已注册,密码重置说明将发送到该邮箱"} if s.Users == nil { writeJSON(writer, http.StatusOK, response) return } user, ok := s.Users.FindByEmail(input.Email) if !ok || !user.Active { writeJSON(writer, http.StatusOK, response) return } rawToken := make([]byte, 32) if _, err := rand.Read(rawToken); err != nil { writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "生成重置令牌失败"}) return } token := hex.EncodeToString(rawToken) s.resetMu.Lock() if s.resetTokens == nil { s.resetTokens = make(map[string]resetToken) } s.resetTokens[token] = resetToken{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(30 * time.Minute)} s.resetMu.Unlock() s.sendUserEmail(user, "Scout 密码重置", "你请求了 Scout 密码重置。\n\n请使用以下一次性令牌完成重置:\n"+token+"\n\n令牌 30 分钟内有效。如果这不是你的操作,请忽略此邮件。") writeJSON(writer, http.StatusOK, response) } func (s *Server) resetPassword(writer http.ResponseWriter, request *http.Request) { var input struct { Token string `json:"token"` NewPassword string `json:"new_password"` } if err := decodeJSON(request, &input); err != nil { writeError(writer, http.StatusBadRequest, err.Error()) return } s.resetMu.Lock() entry, ok := s.resetTokens[strings.TrimSpace(input.Token)] if ok { delete(s.resetTokens, strings.TrimSpace(input.Token)) } s.resetMu.Unlock() if !ok || time.Now().UTC().After(entry.ExpiresAt) || s.Users == nil { writeError(writer, http.StatusBadRequest, "密码重置令牌无效或已过期") return } if err := s.Users.SetPassword(entry.UserID, input.NewPassword); err != nil { writeError(writer, http.StatusBadRequest, err.Error()) return } if user, ok := s.Users.Get(entry.UserID); ok { s.sendUserEmail(user, "Scout 密码已修改", "你的 Scout 密码已修改成功。如果这不是你的操作,请立即联系管理员。") } writeJSON(writer, http.StatusOK, map[string]string{"message": "密码修改成功,请重新登录"}) } func (s *Server) adminUsers(writer http.ResponseWriter, request *http.Request) { if !s.requireRole(writer, request, users.RoleAdmin) { return } writeJSON(writer, http.StatusOK, map[string]any{"users": s.Users.List()}) } func (s *Server) adminUpdateUser(writer http.ResponseWriter, request *http.Request) { if !s.requireRole(writer, request, users.RoleAdmin) { return } id := strings.TrimPrefix(request.URL.Path, "/api/v1/admin/users/") if id == "" { writeError(writer, http.StatusBadRequest, "用户编号不能为空") return } var input updateUserRequest if err := decodeJSON(request, &input); err != nil { writeError(writer, http.StatusBadRequest, err.Error()) return } updated, err := s.Users.Update(id, input.Role, input.Active) if err != nil { writeError(writer, http.StatusBadRequest, err.Error()) return } s.sendUserEmail(updated, "Scout 用户权限已更新", fmt.Sprintf("你的账户角色已调整为 %s,账户状态:%s。", updated.Role, statusText(updated.Active))) writeJSON(writer, http.StatusOK, map[string]any{"user": updated}) } func (s *Server) packageList(writer http.ResponseWriter, request *http.Request) { items := s.Registry.List() query := strings.ToLower(strings.TrimSpace(request.URL.Query().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(request.URL.Query().Get("page"), 1) pageSize := positiveInt(request.URL.Query().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, "需要开发者或管理员权限") } else { 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 { s.notifyUploadFailure(uploader, "上传表单无效: "+err.Error()) writeError(writer, http.StatusBadRequest, "上传表单无效: "+err.Error()) return } file, header, err := request.FormFile("file") if err != nil { s.notifyUploadFailure(uploader, "缺少包文件字段 file") writeError(writer, http.StatusBadRequest, "缺少包文件字段 file") return } defer file.Close() fileName := header.Filename if err := registry.ValidateFileName(fileName); err != nil { s.notifyUploadFailure(uploader, err.Error()) writeError(writer, http.StatusBadRequest, err.Error()) return } 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"), FileName: fileName, UploaderID: uploader.ID, UploaderEmail: uploader.Email, }, file) if err != nil { status := http.StatusBadRequest if strings.Contains(err.Error(), "已经存在") { status = http.StatusConflict } s.notifyUploadFailure(uploader, err.Error()) writeError(writer, status, err.Error()) return } s.sendUserEmail(uploader, "Scout 包上传成功", fmt.Sprintf("包 %s %s 已上传成功。\n\nSHA-256:%s\n大小:%d 字节", item.Name, item.Version, item.SHA256, item.Size)) 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) authenticatedUser(request *http.Request) (users.User, bool) { if s.Users == nil { return users.User{}, false } return s.Users.UserBySession(bearerToken(request)) } func (s *Server) uploadUser(request *http.Request) (users.User, bool) { if s.DeveloperToken != "" && (s.AllowLegacyToken || (s.AllowedHosts == nil && s.AllowedOrigins == nil)) { value := request.Header.Get("Authorization") if strings.HasPrefix(value, "Bearer ") && strings.TrimPrefix(value, "Bearer ") == s.DeveloperToken { return users.User{ID: "legacy-token", Email: "", Name: "兼容令牌", Role: users.RoleDeveloper, Active: true}, true } if request.Header.Get("X-Developer-Token") == s.DeveloperToken { return users.User{ID: "legacy-token", Email: "", Name: "兼容令牌", Role: users.RoleDeveloper, Active: true}, true } } user, ok := s.authenticatedUser(request) if !ok || (user.Role != users.RoleDeveloper && user.Role != users.RoleAdmin) { return users.User{}, false } return user, true } func (s *Server) requireRole(writer http.ResponseWriter, request *http.Request, role users.Role) bool { user, ok := s.authenticatedUser(request) if !ok { writeError(writer, http.StatusUnauthorized, "请先登录") return false } if user.Role != role { writeError(writer, http.StatusForbidden, "没有执行此操作的权限") return false } return true } func (s *Server) sendUserEmail(user users.User, subject, body string) { if user.Email == "" || s.mailer() == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.mailer().Send(ctx, notify.Message{To: []string{user.Email}, Subject: subject, Body: body}); err != nil { s.logger().Printf("发送用户通知失败:%v", err) } } func (s *Server) sendAdminsEmail(subject, body string) { if s.Users == nil || s.mailer() == nil { return } admins := s.Users.ListByRole(users.RoleAdmin) recipients := make([]string, 0, len(admins)) for _, admin := range admins { if admin.Email != "" { recipients = append(recipients, admin.Email) } } if len(recipients) == 0 { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.mailer().Send(ctx, notify.Message{To: recipients, Subject: subject, Body: body}); err != nil { s.logger().Printf("发送管理员通知失败:%v", err) } } func (s *Server) notifyUploadFailure(user users.User, reason string) { if user.Email == "" { return } s.sendUserEmail(user, "Scout 包上传失败", "你的包上传操作失败。\n\n原因:"+reason) } func (s *Server) mailer() notify.Sender { if s.Mailer != nil { return s.Mailer } return nil } func (s *Server) logger() *log.Logger { if s.Logger != nil { return s.Logger } return log.Default() } func bearerToken(request *http.Request) string { value := strings.TrimSpace(request.Header.Get("Authorization")) if strings.HasPrefix(value, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) } return "" } func decodeJSON(request *http.Request, target any) error { request.Body = io.NopCloser(io.LimitReader(request.Body, 1<<20+1)) decoder := json.NewDecoder(request.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return errors.New("请求 JSON 无效") } return nil } func positiveInt(value string, fallback int) int { parsed, err := strconv.Atoi(value) if err != nil || parsed <= 0 { return fallback } return parsed } func statusText(active bool) string { if active { return "启用" } return "禁用" } 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, PATCH, 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) }) }