441 lines
14 KiB
Go
441 lines
14 KiB
Go
// 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/deviceauth"
|
|
"cmbuyer/admin/internal/evidence"
|
|
"cmbuyer/admin/internal/taskclaim"
|
|
"cmbuyer/admin/internal/taskdetail"
|
|
"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
|
|
TaskDetails taskdetail.Store
|
|
Evidence evidence.Store
|
|
DeviceAuthenticator deviceauth.Authenticator
|
|
TaskClaims taskclaim.Service
|
|
}
|
|
|
|
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
|
func NewRouter(options Options) (*gin.Engine, error) {
|
|
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == 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/:id", taskDetailPage(options))
|
|
router.GET("/tasks/new", newTaskPage(options))
|
|
router.POST("/tasks", createTask(options))
|
|
router.POST("/tasks/start-purchases", startPurchases(options))
|
|
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
|
|
router.POST("/api/v1/tasks/claim-next", claimNext(options))
|
|
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options))
|
|
router.GET("/evidence/:asset_id", readEvidence(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 !options.Sessions.IsAuthenticated(context.Request) {
|
|
context.Status(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
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
|
|
}
|