// Package server 定义采购服务当前拥有的 HTTP 端点。 package server import ( "bytes" "crypto/subtle" "encoding/json" "errors" "io" "mime" "net/http" "net/url" "strings" "unicode/utf8" "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/transport/webui" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" ) const maxFormBytes = 8 << 10 const maxJSONBytes = 64 << 10 // Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。 type Options struct { AdminUsername string AdminPasswordBcrypt string Sessions *auth.Manager Tasks tasks.Store } // NewRouter 返回当前服务范围内的完整 HTTP 路由。 func NewRouter(options Options) (*gin.Engine, error) { if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil { return nil, errors.New("server authentication options are incomplete") } router := gin.New() router.Use(gin.Recovery()) router.Use(securityHeaders()) router.GET("/healthz", healthz) router.GET("/login", loginPage(options)) router.POST("/login", login(options)) router.POST("/logout", logout(options)) router.GET("/tasks", tasksPage(options)) router.GET("/tasks/new", newTaskPage(options)) router.POST("/tasks", createTask(options)) router.POST("/tasks/start-purchases", startPurchases(options)) router.GET("/static/tasks.js", func(context *gin.Context) { context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript()) }) return router, nil } func startPurchases(options Options) gin.HandlerFunc { return func(context *gin.Context) { if !options.Sessions.IsAuthenticated(context.Request) { context.Status(http.StatusUnauthorized) return } authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, context.GetHeader("X-CSRF-Token")) if !authenticated || !csrfOK { context.Status(http.StatusForbidden) return } if !isJSONContentType(context.GetHeader("Content-Type")) { context.Status(http.StatusUnsupportedMediaType) return } context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxJSONBytes) raw, err := io.ReadAll(context.Request.Body) if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { context.Status(http.StatusRequestEntityTooLarge) } else { context.Status(http.StatusBadRequest) } return } if !utf8.Valid(raw) { context.Status(http.StatusBadRequest) return } decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() var command tasks.StartCommand if err := decoder.Decode(&command); err != nil { context.Status(http.StatusBadRequest) return } var extra any if err := decoder.Decode(&extra); err != io.EOF { context.Status(http.StatusBadRequest) return } result, err := options.Tasks.StartPurchases(context.Request.Context(), command, options.AdminUsername) if err != nil { if errors.Is(err, tasks.ErrInvalidStart) { context.Status(http.StatusBadRequest) } else if errors.Is(err, tasks.ErrStartConflict) { context.Status(http.StatusConflict) } else { context.Status(http.StatusInternalServerError) } return } context.JSON(http.StatusOK, result) } } func isJSONContentType(value string) bool { mediaType, parameters, err := mime.ParseMediaType(value) if err != nil || mediaType != "application/json" { return false } for name, value := range parameters { if name != "charset" || !strings.EqualFold(value, "utf-8") { return false } } return true } func healthz(context *gin.Context) { context.JSON(http.StatusOK, gin.H{"status": "ok"}) } func securityHeaders() gin.HandlerFunc { return func(context *gin.Context) { context.Header("Cache-Control", "no-store") context.Header("X-Content-Type-Options", "nosniff") context.Header("Referrer-Policy", "no-referrer") context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") context.Next() } } func loginPage(options Options) gin.HandlerFunc { return func(context *gin.Context) { csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request) if authenticated { context.Redirect(http.StatusSeeOther, "/tasks") return } renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "") } } func login(options Options) gin.HandlerFunc { return func(context *gin.Context) { if !parseForm(context) { return } form := context.Request.PostForm csrfToken := form.Get("csrf_token") returnPath := returnTo(form.Get("return_to")) username := form.Get("username") password := form.Get("password") if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok { newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request) renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。") return } usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1 passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil if !usernameMatches || !passwordMatches { csrf, _ := options.Sessions.Ensure(context.Writer, context.Request) renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。") return } options.Sessions.RotateAuthenticated(context.Writer, context.Request) context.Redirect(http.StatusSeeOther, returnPath) } } func logout(options Options) gin.HandlerFunc { return func(context *gin.Context) { if !parseForm(context) { return } authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token")) if !ok || !authenticated { context.Status(http.StatusForbidden) return } options.Sessions.Logout(context.Writer, context.Request) context.Redirect(http.StatusSeeOther, "/login") } } func tasksPage(options Options) gin.HandlerFunc { return func(context *gin.Context) { csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request) if !authenticated { context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI())) return } filter := tasks.TaskFilter{Keyword: context.Query("keyword"), Status: context.Query("status"), CreatedFrom: context.Query("created_from"), CreatedTo: context.Query("created_to")} if validation := tasks.ValidateTaskFilter(filter); !validation.Valid() { startKey, err := tasks.NewCreateKey() if err != nil { context.Status(http.StatusInternalServerError) return } renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfToken, Filter: filter, FilterErrors: validation, HasFilter: true, StartKey: startKey}) return } data, err := taskListData(context, options, csrfToken, filter) if err != nil { context.Status(http.StatusInternalServerError) return } for _, row := range data.Tasks { if row.ID == context.Query("created") { data.Success = true break } } if context.Query("create") == "1" { form, err := newTaskForm() if err != nil { context.Status(http.StatusInternalServerError) return } data.OpenForm = true data.Form = form data.FocusField = "title" } renderTasks(context, http.StatusOK, data) } } func newTaskPage(options Options) gin.HandlerFunc { return func(context *gin.Context) { csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request) if !authenticated { context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew") return } form, err := newTaskForm() if err != nil { context.Status(http.StatusInternalServerError) return } renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"}) } } func createTask(options Options) gin.HandlerFunc { return func(context *gin.Context) { if !parseForm(context) { return } requestForm := context.Request.PostForm authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token")) if !csrfOK || !authenticated { context.Status(http.StatusForbidden) return } form := taskForm(requestForm) draft, validation := tasks.Validate(form) if draft.GoodsID != "" { form.ProductURL = tasks.CanonicalURL(draft.GoodsID) } fullPage := requestForm.Get("form_mode") == "full" if !validation.Valid() { data, ok := createErrorData(context, options, fullPage) if !ok { return } data.Form = form data.Errors = validation data.OpenForm = !fullPage data.FullPage = fullPage data.FocusField = firstError(validation) renderTasks(context, http.StatusBadRequest, data) return } created, err := options.Tasks.CreateDraft(context.Request.Context(), draft) if err != nil { if errors.Is(err, tasks.ErrCreateKeyConflict) { validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。" data, ok := createErrorData(context, options, fullPage) if !ok { return } data.Form = form data.Errors = validation data.OpenForm = !fullPage data.FullPage = fullPage data.FocusField = firstError(validation) renderTasks(context, http.StatusConflict, data) return } context.Status(http.StatusInternalServerError) return } context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID)) } } func newTaskForm() (tasks.Form, error) { key, err := tasks.NewCreateKey() if err != nil { return tasks.Form{}, err } return tasks.Form{CreateKey: key}, nil } func taskForm(form url.Values) tasks.Form { return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")} } func csrfFor(context *gin.Context, options Options) string { csrf, _ := options.Sessions.Ensure(context.Writer, context.Request) return csrf } func taskListData(context *gin.Context, options Options, csrfToken string, filter tasks.TaskFilter) (webui.TasksData, error) { rows, err := options.Tasks.ListTasks(context.Request.Context(), filter) if err != nil { return webui.TasksData{}, err } startKey, err := tasks.NewCreateKey() if err != nil { return webui.TasksData{}, err } return webui.TasksData{ CSRFToken: csrfToken, Tasks: rows, Filter: filter, HasFilter: filter.Keyword != "" || filter.Status != "" || filter.CreatedFrom != "" || filter.CreatedTo != "", StartKey: startKey, }, nil } func createErrorData(context *gin.Context, options Options, fullPage bool) (webui.TasksData, bool) { csrfToken := csrfFor(context, options) if fullPage { return webui.TasksData{CSRFToken: csrfToken}, true } data, err := taskListData(context, options, csrfToken, tasks.TaskFilter{}) if err != nil { context.Status(http.StatusInternalServerError) return webui.TasksData{}, false } return data, true } func renderTasks(context *gin.Context, status int, data webui.TasksData) { context.Header("Content-Type", "text/html; charset=utf-8") context.Status(status) if err := webui.RenderTasks(context.Writer, data); err != nil { _ = context.Error(err) } } func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) { context.Header("Content-Type", "text/html; charset=utf-8") context.Status(status) if err := webui.RenderLogin(context.Writer, webui.LoginData{ CSRFToken: csrfToken, ReturnTo: returnPath, Username: username, Error: message, }); err != nil { _ = context.Error(err) } } func parseForm(context *gin.Context) bool { context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes) if err := context.Request.ParseForm(); err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { context.Status(http.StatusRequestEntityTooLarge) } else { context.Status(http.StatusBadRequest) } return false } return true } func firstError(validation tasks.Errors) string { for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} { if _, ok := validation[field]; ok { return field } } return "title" } func returnTo(value string) string { if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") { if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") { return "/tasks" } parsed, err := url.ParseRequestURI(value) if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) { return value } } return "/tasks" } func hasSafeTaskPath(path string) bool { for _, segment := range strings.Split(path, "/") { if segment == "." || segment == ".." { return false } } return true }