Scout/internal/httpapi/server.go
2026-08-31 21:19:15 +08:00

566 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"scout/internal/access"
"scout/internal/notify"
"scout/internal/registry"
"scout/internal/users"
)
type Server struct {
Registry *registry.Registry
Projects *registry.ProjectRegistry
Access *access.Store
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
projectScope string
}
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/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)
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) legacyPackageList(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) legacyUpload(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) legacyPackageRoute(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) legacyDownload(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) legacyStream(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, X-Project-Token")
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, 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)
})
}