// Package server 定义采购服务当前拥有的 HTTP 端点。 package server import ( "crypto/subtle" "errors" "net/http" "net/url" "strings" "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 // 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)) return router, nil } 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 'none'; 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 } drafts, err := options.Tasks.ListDrafts(context.Request.Context()) if err != nil { context.Status(http.StatusInternalServerError) return } data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts} for _, draft := range drafts { if draft.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() { drafts, err := options.Tasks.ListDrafts(context.Request.Context()) if err != nil { context.Status(http.StatusInternalServerError) return } renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) return } created, err := options.Tasks.CreateDraft(context.Request.Context(), draft) if err != nil { if errors.Is(err, tasks.ErrCreateKeyConflict) { validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。" drafts, listErr := options.Tasks.ListDrafts(context.Request.Context()) if listErr != nil { context.Status(http.StatusInternalServerError) return } renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) 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 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 }