Files
Sudo-Ivan 0e6fafe583
Some checks failed
ci / build-frontend (push) Failing after 1m8s
ci / test (push) Successful in 1m56s
ci / build-backend (amd64, linux) (push) Failing after 30s
ci / build-backend (arm64, linux) (push) Failing after 28s
Enhance feed management and deduplication features
- Added deduplication logic in the backend to normalize feed links and remove duplicates based on normalized URLs.
- Introduced a limit parameter in feed listing to control the number of feeds returned.
- Updated frontend components to support new feed limit functionality and improved feed import handling.
- Enhanced error handling and loading states in the settings section for better user experience.
2025-12-10 11:46:59 -06:00

239 lines
6.8 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Sudo-Ivan/fusionx/auth"
"github.com/Sudo-Ivan/fusionx/conf"
"github.com/Sudo-Ivan/fusionx/frontend"
"github.com/Sudo-Ivan/fusionx/repo"
"github.com/Sudo-Ivan/fusionx/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
DBPath string
DemoMode bool
}
func Run(params Params) {
r := echo.New()
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("...")...)
}
slog.Debug("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 {
slog.Info("REQUEST", "uri", v.URI, "status", v.Status)
} else {
slog.Error(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 && !params.DemoMode {
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)
}
if params.DemoMode {
authed.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
method := c.Request().Method
path := c.Request().URL.Path
// Allow feed refresh in demo mode (read-only operation)
if method == "POST" && path == "/api/feeds/refresh" {
return next(c)
}
if method == "POST" || method == "PATCH" || method == "DELETE" {
return echo.NewHTTPError(http.StatusForbidden, "Demo mode: write operations not allowed")
}
return next(c)
}
})
}
feeds := authed.Group("/feeds")
feedService := server.NewFeed(repo.NewFeed(repo.DB))
if err := feedService.DeduplicateExisting(context.Background()); err != nil {
slog.Warn("feed deduplication failed", "error", err)
}
feedAPIHandler := newFeedAPI(feedService)
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)
favicons := authed.Group("/favicons")
faviconAPIHandler := newFaviconAPI("./cache/favicons")
favicons.GET("/:filename", faviconAPIHandler.ServeFavicon)
statsAPIHandler := newStatsAPI(server.NewStats(repo.NewStats(repo.DB), params.DBPath))
authed.GET("/stats", statsAPIHandler.Get)
configAPIHandler := newConfigAPI(server.NewConfig(repo.NewConfig(repo.DB), params.DemoMode))
authed.GET("/config", configAPIHandler.Get)
authed.PATCH("/config", configAPIHandler.Update)
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 {
slog.Error(err.Error())
return
}
}
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 {
// #nosec G115 - HTTPCode is validated to be within HTTP status code range in BizError creation
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()
// #nosec G104 - Translation registration errors are non-critical for validator functionality
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)
}