295 lines
9.4 KiB
Go
295 lines
9.4 KiB
Go
// 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
|
|
}
|