1
0
Fork 0
forked from 0x2E/fusion
fusion/api/api.go
Yuan dd0d2e7d7d
feat: make the password optional (#128)
* feat: make the password optional

* fix: put the check outside HashPassword

* refactor: change PasswordHash to a pointer type for better checking
2025-04-05 20:14:31 +08:00

199 lines
5.4 KiB
Go

package api
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/0x2e/fusion/auth"
"github.com/0x2e/fusion/conf"
"github.com/0x2e/fusion/frontend"
"github.com/0x2e/fusion/pkg/logx"
"github.com/0x2e/fusion/repo"
"github.com/0x2e/fusion/server"
"github.com/go-playground/locales/en"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
en_translations "github.com/go-playground/validator/v10/translations/en"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type Params struct {
Host string
Port int
PasswordHash *auth.HashedPassword
UseSecureCookie bool
TLSCert string
TLSKey string
}
func Run(params Params) {
r := echo.New()
apiLogger := logx.Logger.With("module", "api")
if conf.Debug {
r.Debug = true
r.Use(middleware.BodyDump(func(c echo.Context, reqBody, resBody []byte) {
if len(resBody) > 500 {
resBody = append(resBody[:500], []byte("...")...)
}
apiLogger.Debugw("body dump", "req", reqBody, "resp", resBody)
}))
}
r.HideBanner = true
r.HTTPErrorHandler = errorHandler
r.Validator = newCustomValidator()
r.Use(middleware.Recover())
r.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogStatus: true,
LogURI: true,
LogError: true,
HandleError: true, // forwards error to the global error handler, so it can decide appropriate status code
LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
if !strings.HasPrefix(v.URI, "/api") {
return nil
}
if v.Error == nil {
apiLogger.Infow("REQUEST", "uri", v.URI, "status", v.Status)
} else {
apiLogger.Errorw(v.Error.Error(), "uri", v.URI, "status", v.Status)
}
return nil
},
}))
r.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Timeout: 30 * time.Second,
}))
if params.PasswordHash != nil {
r.Use(session.Middleware(sessions.NewCookieStore(params.PasswordHash.Bytes())))
}
r.Pre(middleware.RemoveTrailingSlash())
r.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if strings.HasPrefix(c.Request().URL.Path, "/_app/") {
c.Response().Header().Set("Cache-Control", "public, max-age=2592000")
}
return next(c)
}
})
r.Use(middleware.StaticWithConfig(middleware.StaticConfig{
HTML5: true,
Index: "index.html",
Filesystem: http.FS(frontend.Content),
Browse: false,
}))
authed := r.Group("/api")
if params.PasswordHash != nil {
loginAPI := Session{
PasswordHash: *params.PasswordHash,
UseSecureCookie: params.UseSecureCookie,
}
r.POST("/api/sessions", loginAPI.Create)
authed.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if err := loginAPI.Check(c); err != nil {
return echo.NewHTTPError(http.StatusUnauthorized)
}
return next(c)
}
})
authed.DELETE("/sessions", loginAPI.Delete)
}
feeds := authed.Group("/feeds")
feedAPIHandler := newFeedAPI(server.NewFeed(repo.NewFeed(repo.DB)))
feeds.GET("", feedAPIHandler.List)
feeds.GET("/:id", feedAPIHandler.Get)
feeds.POST("", feedAPIHandler.Create)
feeds.POST("/validation", feedAPIHandler.CheckValidity)
feeds.PATCH("/:id", feedAPIHandler.Update)
feeds.DELETE("/:id", feedAPIHandler.Delete)
feeds.POST("/refresh", feedAPIHandler.Refresh)
groups := authed.Group("/groups")
groupAPIHandler := newGroupAPI(server.NewGroup(repo.NewGroup(repo.DB)))
groups.GET("", groupAPIHandler.All)
groups.POST("", groupAPIHandler.Create)
groups.PATCH("/:id", groupAPIHandler.Update)
groups.DELETE("/:id", groupAPIHandler.Delete)
items := authed.Group("/items")
itemAPIHandler := newItemAPI(server.NewItem(repo.NewItem(repo.DB)))
items.GET("", itemAPIHandler.List)
items.GET("/:id", itemAPIHandler.Get)
items.PATCH("/:id/bookmark", itemAPIHandler.UpdateBookmark)
items.PATCH("/-/unread", itemAPIHandler.UpdateUnread)
items.DELETE("/:id", itemAPIHandler.Delete)
var err error
addr := fmt.Sprintf("%s:%d", params.Host, params.Port)
if params.TLSCert != "" {
err = r.StartTLS(addr, params.TLSCert, params.TLSKey)
} else {
err = r.Start(addr)
}
if err != nil {
apiLogger.Fatalln(err)
}
}
func errorHandler(err error, c echo.Context) {
if errors.Is(err, repo.ErrNotFound) {
err = echo.NewHTTPError(http.StatusNotFound, "Resource not exists")
} else {
if bizerr, ok := err.(server.BizError); ok {
err = echo.NewHTTPError(int(bizerr.HTTPCode), bizerr.FEMessage)
}
}
c.Echo().DefaultHTTPErrorHandler(err, c)
}
type CustomValidator struct {
handler *validator.Validate
trans ut.Translator
}
func newCustomValidator() *CustomValidator {
en := en.New()
uni := ut.New(en, en)
trans, _ := uni.GetTranslator("en")
validate := validator.New()
en_translations.RegisterDefaultTranslations(validate, trans)
return &CustomValidator{
handler: validate,
trans: trans,
}
}
func (v *CustomValidator) Validate(i interface{}) error {
err := v.handler.Struct(i)
if err != nil {
errs := err.(validator.ValidationErrors)
msg := strings.Builder{}
for _, content := range errs.Translate(v.trans) {
msg.WriteString(content)
msg.WriteString(".")
}
err = echo.NewHTTPError(http.StatusBadRequest, msg.String())
}
return err
}
func bindAndValidate(i interface{}, c echo.Context) error {
if err := c.Bind(i); err != nil {
return err
}
return c.Validate(i)
}