From c00b690bd6f600554a1404e692bd9e4373325d27 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Fri, 17 Jan 2025 13:21:24 +0000 Subject: Initial commit --- api/dto/favourites.go | 34 ++++++++++++++ api/dto/response.go | 21 +++++++++ api/dto/schedule.go | 10 ++++ api/dto/users.go | 20 ++++++++ api/handlers/favourites.go | 112 +++++++++++++++++++++++++++++++++++++++++++++ api/handlers/schedule.go | 25 ++++++++++ api/handlers/users.go | 112 +++++++++++++++++++++++++++++++++++++++++++++ api/handlers/util.go | 28 ++++++++++++ api/middleware/auth.go | 46 +++++++++++++++++++ api/router.go | 67 +++++++++++++++++++++++++++ 10 files changed, 475 insertions(+) create mode 100644 api/dto/favourites.go create mode 100644 api/dto/response.go create mode 100644 api/dto/schedule.go create mode 100644 api/dto/users.go create mode 100644 api/handlers/favourites.go create mode 100644 api/handlers/schedule.go create mode 100644 api/handlers/users.go create mode 100644 api/handlers/util.go create mode 100644 api/middleware/auth.go create mode 100644 api/router.go (limited to 'api') diff --git a/api/dto/favourites.go b/api/dto/favourites.go new file mode 100644 index 0000000..0f8021f --- /dev/null +++ b/api/dto/favourites.go @@ -0,0 +1,34 @@ +package dto + +import "github.com/LMBishop/confplanner/pkg/database/sqlc" + +type CreateFavouritesRequest struct { + GUID *string `json:"eventGuid"` + ID *int32 `json:"eventId"` +} + +type CreateFavouritesResponse struct { + ID int32 `json:"id"` +} + +type GetFavouritesResponse struct { + ID int32 `json:"id"` + GUID *string `json:"eventGuid,omitempty"` + EventID *int32 `json:"eventId,omitempty"` +} + +func (dst *GetFavouritesResponse) Scan(src sqlc.Favourite) { + dst.ID = src.ID + if src.EventGuid.Valid { + strGuid := src.EventGuid.String() + dst.GUID = &strGuid + } + if src.EventID.Valid { + dst.EventID = &src.EventID.Int32 + } +} + +type DeleteFavouritesRequest struct { + GUID *string `json:"eventGuid"` + ID *int32 `json:"eventId"` +} diff --git a/api/dto/response.go b/api/dto/response.go new file mode 100644 index 0000000..43f98bd --- /dev/null +++ b/api/dto/response.go @@ -0,0 +1,21 @@ +package dto + +import "fmt" + +type OkResponse struct { + Code int `json:"code"` + Data interface{} `json:"data,omitempty"` +} + +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (r *OkResponse) Error() string { + return fmt.Sprintf("HTTP status %d", r.Code) +} + +func (r *ErrorResponse) Error() string { + return fmt.Sprintf("HTTP status %d: %s", r.Code, r.Message) +} diff --git a/api/dto/schedule.go b/api/dto/schedule.go new file mode 100644 index 0000000..0d652aa --- /dev/null +++ b/api/dto/schedule.go @@ -0,0 +1,10 @@ +package dto + +import ( + "time" +) + +type GetScheduleResponse struct { + Schedule interface{} `json:"schedule"` + LastUpdated time.Time `json:"lastUpdated"` +} diff --git a/api/dto/users.go b/api/dto/users.go new file mode 100644 index 0000000..685fa07 --- /dev/null +++ b/api/dto/users.go @@ -0,0 +1,20 @@ +package dto + +type RegisterRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type RegisterResponse struct { + ID int32 `json:"id"` +} + +type LoginRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type LoginResponse struct { + ID int32 `json:"id"` + Username string `json:"username"` +} diff --git a/api/handlers/favourites.go b/api/handlers/favourites.go new file mode 100644 index 0000000..862d366 --- /dev/null +++ b/api/handlers/favourites.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/favourites" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5/pgtype" +) + +func CreateFavourite(service favourites.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var request dto.CreateFavouritesRequest + if err := readBody(c, &request); err != nil { + return err + } + + if request.GUID == nil && request.ID == nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "One of event GUID or event ID must be specified", + } + } + + uid := c.Locals("uid").(int32) + var uuid pgtype.UUID + if request.GUID != nil { + if err := uuid.Scan(*request.GUID); err != nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "Bad event GUID", + } + } + } + + createdFavourite, err := service.CreateFavouriteForUser(uid, uuid, request.ID) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusCreated, + Data: &dto.CreateFavouritesResponse{ + ID: createdFavourite.ID, + }, + } + } +} + +func GetFavourites(service favourites.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + uid := c.Locals("uid").(int32) + + favourites, err := service.GetFavouritesForUser(uid) + if err != nil { + return err + } + + favouritesResponse := make([]dto.GetFavouritesResponse, 0) + for _, favourite := range *favourites { + var favouriteResponse dto.GetFavouritesResponse + favouriteResponse.Scan(favourite) + + favouritesResponse = append(favouritesResponse, favouriteResponse) + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + Data: favouritesResponse, + } + } +} + +func DeleteFavourite(service favourites.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var request dto.DeleteFavouritesRequest + if err := readBody(c, &request); err != nil { + return err + } + + if request.GUID == nil && request.ID == nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "One of event GUID or event ID must be specified", + } + } + + uid := c.Locals("uid").(int32) + var err error + var uuid pgtype.UUID + if err := uuid.Scan(*request.GUID); err != nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "Bad event GUID", + } + } + + err = service.DeleteFavouriteForUserByEventDetails(uid, uuid, request.ID) + if err != nil { + if err == favourites.ErrNotFound { + return &dto.ErrorResponse{ + Code: fiber.StatusNotFound, + Message: "Favourite not found", + } + } + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + } + } +} diff --git a/api/handlers/schedule.go b/api/handlers/schedule.go new file mode 100644 index 0000000..fd3a183 --- /dev/null +++ b/api/handlers/schedule.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/schedule" + "github.com/gofiber/fiber/v2" + "github.com/golang-cz/nilslice" +) + +func GetSchedule(service schedule.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + schedule, lastUpdated, err := service.GetSchedule() + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + Data: &dto.GetScheduleResponse{ + Schedule: nilslice.Initialize(*schedule), + LastUpdated: *lastUpdated, + }, + } + } +} diff --git a/api/handlers/users.go b/api/handlers/users.go new file mode 100644 index 0000000..deda9ca --- /dev/null +++ b/api/handlers/users.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "errors" + "time" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/user" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" +) + +func Register(service user.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var request dto.RegisterRequest + if err := readBody(c, &request); err != nil { + return err + } + + createdUser, err := service.CreateUser(request.Username, request.Password) + if err != nil { + if errors.Is(err, user.ErrUserExists) { + return &dto.ErrorResponse{ + Code: fiber.StatusConflict, + Message: "User with that username already exists", + } + } else if errors.Is(err, user.ErrNotAcceptingRegistrations) { + return &dto.ErrorResponse{ + Code: fiber.StatusForbidden, + Message: "This service is not currently accepting registrations", + } + } + + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusCreated, + Data: &dto.RegisterResponse{ + ID: createdUser.ID, + }, + } + } +} + +func Login(service user.Service, store *session.Store) fiber.Handler { + return func(c *fiber.Ctx) error { + var request dto.LoginRequest + if err := readBody(c, &request); err != nil { + return err + } + + user, err := service.Authenticate(request.Username, request.Password) + if err != nil { + return err + } + + if user == nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "Username and password combination not found", + } + } + + s, err := store.Get(c) + if err != nil { + return err + } + + if s.Fresh() { + uid := user.ID + sid := s.ID() + + s.Set("uid", uid) + s.Set("sid", sid) + s.Set("ip", c.Context().RemoteIP().String()) + s.Set("login", time.Unix(time.Now().Unix(), 0).UTC().String()) + s.Set("ua", string(c.Request().Header.UserAgent())) + + err = s.Save() + if err != nil { + return err + } + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + Data: &dto.LoginResponse{ + ID: user.ID, + Username: user.Username, + }, + } + } +} + +func Logout(store *session.Store) fiber.Handler { + return func(c *fiber.Ctx) error { + s, err := store.Get(c) + if err != nil { + return err + } + + err = s.Destroy() + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusNoContent, + } + } +} diff --git a/api/handlers/util.go b/api/handlers/util.go new file mode 100644 index 0000000..b0cf344 --- /dev/null +++ b/api/handlers/util.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +var validate = validator.New(validator.WithRequiredStructEnabled()) + +func readBody(c *fiber.Ctx, request interface{}) error { + if err := c.BodyParser(request); err != nil { + return &fiber.Error{ + Code: fiber.StatusBadRequest, + Message: fmt.Errorf("Invalid request (%w)", err).Error(), + } + } + + if err := validate.Struct(request); err != nil { + return &fiber.Error{ + Code: fiber.StatusBadRequest, + Message: err.Error(), + } + } + + return nil +} diff --git a/api/middleware/auth.go b/api/middleware/auth.go new file mode 100644 index 0000000..611276a --- /dev/null +++ b/api/middleware/auth.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "errors" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/user" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" +) + +func RequireAuthenticated(service user.Service, store *session.Store) fiber.Handler { + return func(c *fiber.Ctx) error { + s, err := store.Get(c) + if err != nil { + return err + } + + if s.Fresh() || len(s.Keys()) == 0 { + return &dto.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Message: "Unauthorized", + } + } + + uid := s.Get("uid").(int32) + + fetchedUser, err := service.GetUserByID(uid) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + s.Destroy() + return &dto.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Message: "Invalid session", + } + } + + return err + } + + c.Locals("uid", uid) + c.Locals("username", fetchedUser.Username) + + return c.Next() + } +} diff --git a/api/router.go b/api/router.go new file mode 100644 index 0000000..82a29b1 --- /dev/null +++ b/api/router.go @@ -0,0 +1,67 @@ +package api + +import ( + "errors" + "log/slog" + "time" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/api/handlers" + "github.com/LMBishop/confplanner/api/middleware" + "github.com/LMBishop/confplanner/pkg/favourites" + "github.com/LMBishop/confplanner/pkg/schedule" + "github.com/LMBishop/confplanner/pkg/user" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" +) + +type ApiServices struct { + UserService user.Service + FavouritesService favourites.Service + ScheduleService schedule.Service +} + +func NewServer(apiServices ApiServices) *fiber.App { + sessionStore := session.New(session.Config{ + Expiration: 24 * time.Hour, + KeyLookup: "cookie:confplanner_session", + CookieSameSite: "None", + }) + + app := fiber.New(fiber.Config{ + ErrorHandler: func(ctx *fiber.Ctx, err error) error { + var ok *dto.OkResponse + if errors.As(err, &ok) { + return ctx.Status(ok.Code).JSON(ok) + } + + var e *dto.ErrorResponse + if errors.As(err, &e) { + return ctx.Status(e.Code).JSON(e) + } + + slog.Error("fiber runtime error", "error", err, "URL", ctx.OriginalURL()) + return ctx.Status(500).JSON(dto.ErrorResponse{ + Code: 500, + Message: "Internal Server Error", + }) + }, + AppName: "confplanner", + }) + + // app.Use(cors.New()) + + requireAuthenticated := middleware.RequireAuthenticated(apiServices.UserService, sessionStore) + + app.Post("/register", handlers.Register(apiServices.UserService)) + app.Post("/login", handlers.Login(apiServices.UserService, sessionStore)) + app.Post("/logout", requireAuthenticated, handlers.Logout(sessionStore)) + + app.Get("/favourites", requireAuthenticated, handlers.GetFavourites(apiServices.FavouritesService)) + app.Post("/favourites", requireAuthenticated, handlers.CreateFavourite(apiServices.FavouritesService)) + app.Delete("/favourites", requireAuthenticated, handlers.DeleteFavourite(apiServices.FavouritesService)) + + app.Get("/schedule", requireAuthenticated, handlers.GetSchedule(apiServices.ScheduleService)) + + return app +} -- cgit v1.2.3-70-g09d2