From ecc6a55aba7bb35fc778e7a53848396b88214151 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Sat, 23 Aug 2025 22:29:28 +0100 Subject: Add multiple conferences feature --- api/dto/auth.go | 4 +- api/dto/conference.go | 36 ++++++++++++++++ api/dto/favourites.go | 10 +++-- api/dto/schedule.go | 10 ----- api/dto/util.go | 6 +-- api/handlers/auth.go | 11 ++--- api/handlers/conference.go | 103 +++++++++++++++++++++++++++++++++++++++++++++ api/handlers/favourites.go | 19 ++++++--- api/handlers/schedule.go | 26 ------------ api/middleware/admin.go | 27 ++++++++++++ api/middleware/auth.go | 38 +++++++++++++---- api/router.go | 14 +++--- 12 files changed, 234 insertions(+), 70 deletions(-) create mode 100644 api/dto/conference.go delete mode 100644 api/dto/schedule.go create mode 100644 api/handlers/conference.go delete mode 100644 api/handlers/schedule.go create mode 100644 api/middleware/admin.go (limited to 'api') diff --git a/api/dto/auth.go b/api/dto/auth.go index 0379f21..734bb31 100644 --- a/api/dto/auth.go +++ b/api/dto/auth.go @@ -11,12 +11,14 @@ type LoginOAuthCallbackRequest struct { } type LoginOAuthOutboundResponse struct { - URL string `json:"url" validate:"required"` + URL string `json:"url"` } type LoginResponse struct { ID int32 `json:"id"` + Token string `json:"token"` Username string `json:"username"` + Admin bool `json:"admin"` } type LoginOptionsResponse struct { diff --git a/api/dto/conference.go b/api/dto/conference.go new file mode 100644 index 0000000..99e6c08 --- /dev/null +++ b/api/dto/conference.go @@ -0,0 +1,36 @@ +package dto + +import ( + "time" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" +) + +type ConferenceResponse struct { + ID int32 `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + Venue string `json:"venue"` + City string `json:"city"` +} + +func (dst *ConferenceResponse) Scan(src sqlc.Conference) { + dst.ID = src.ID + dst.Title = src.Title.String + dst.URL = src.Url + dst.Venue = src.Venue.String + dst.City = src.City.String +} + +type GetScheduleResponse struct { + Schedule interface{} `json:"schedule"` + LastUpdated time.Time `json:"lastUpdated"` +} + +type CreateConferenceRequest struct { + URL string `json:"url" validate:"required"` +} + +type DeleteConferenceRequest struct { + ID int32 `json:"id"` +} diff --git a/api/dto/favourites.go b/api/dto/favourites.go index 0f8021f..44c68a2 100644 --- a/api/dto/favourites.go +++ b/api/dto/favourites.go @@ -3,8 +3,9 @@ package dto import "github.com/LMBishop/confplanner/pkg/database/sqlc" type CreateFavouritesRequest struct { - GUID *string `json:"eventGuid"` - ID *int32 `json:"eventId"` + ConferenceID int32 `json:"conferenceID" validate:"required"` + GUID *string `json:"eventGuid"` + EventID *int32 `json:"eventId"` } type CreateFavouritesResponse struct { @@ -29,6 +30,7 @@ func (dst *GetFavouritesResponse) Scan(src sqlc.Favourite) { } type DeleteFavouritesRequest struct { - GUID *string `json:"eventGuid"` - ID *int32 `json:"eventId"` + ConferenceID int32 `json:"conferenceID" validate:"required"` + GUID *string `json:"eventGuid"` + EventID *int32 `json:"eventId"` } diff --git a/api/dto/schedule.go b/api/dto/schedule.go deleted file mode 100644 index 0d652aa..0000000 --- a/api/dto/schedule.go +++ /dev/null @@ -1,10 +0,0 @@ -package dto - -import ( - "time" -) - -type GetScheduleResponse struct { - Schedule interface{} `json:"schedule"` - LastUpdated time.Time `json:"lastUpdated"` -} diff --git a/api/dto/util.go b/api/dto/util.go index cb3ea52..651f026 100644 --- a/api/dto/util.go +++ b/api/dto/util.go @@ -36,8 +36,8 @@ func WrapResponseFunc(dtoFunc func(http.ResponseWriter, *http.Request) error) ht } } -func WriteDto(w http.ResponseWriter, r *http.Request, o error) { - if o, ok := o.(Response); ok { +func WriteDto(w http.ResponseWriter, r *http.Request, err error) { + if o, ok := err.(Response); ok { data, err := json.Marshal(o) if err != nil { w.WriteHeader(500) @@ -49,6 +49,6 @@ func WriteDto(w http.ResponseWriter, r *http.Request, o error) { w.Write(data) } else { w.WriteHeader(500) - slog.Error("internal server error handling request", "error", o) + slog.Error("internal server error handling request", "error", err) } } diff --git a/api/handlers/auth.go b/api/handlers/auth.go index c19fc3a..7b0a4c8 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -34,23 +34,18 @@ func Login(authService auth.Service, store session.Service) http.HandlerFunc { } // TODO X-Forwarded-For - session, err := store.Create(user.ID, user.Username, r.RemoteAddr, r.UserAgent()) + session, err := store.Create(user.ID, user.Username, r.RemoteAddr, r.UserAgent(), user.Admin) if err != nil { return err } - cookie := &http.Cookie{ - Name: "confplanner_session", - Value: session.Token, - Path: "/api", - } - http.SetCookie(w, cookie) - return &dto.OkResponse{ Code: http.StatusOK, Data: &dto.LoginResponse{ ID: user.ID, + Token: session.Token, Username: user.Username, + Admin: session.Admin, }, } }) diff --git a/api/handlers/conference.go b/api/handlers/conference.go new file mode 100644 index 0000000..42cbfc1 --- /dev/null +++ b/api/handlers/conference.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/conference" + "github.com/golang-cz/nilslice" +) + +func GetSchedule(service conference.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + conferenceID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + return &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Bad conference ID", + } + } + + schedule, lastUpdated, err := service.GetSchedule(int32(conferenceID)) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: &dto.GetScheduleResponse{ + Schedule: nilslice.Initialize(*schedule), + LastUpdated: lastUpdated, + }, + } + }) +} + +func GetConferences(service conference.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + conferences, err := service.GetConferences() + if err != nil { + return err + } + + var conferencesResponse []*dto.ConferenceResponse + for _, c := range conferences { + conference := &dto.ConferenceResponse{} + conference.Scan(c) + conferencesResponse = append(conferencesResponse, conference) + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: conferencesResponse, + } + }) +} + +func CreateConference(service conference.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.CreateConferenceRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + createdConference, err := service.CreateConference(request.URL) + if err != nil { + if errors.Is(err, conference.ErrScheduleFetch) { + return &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Could not fetch schedule from URL (is it a valid pentabarf XML file?)", + } + } + return err + } + + var response dto.ConferenceResponse + response.Scan(*createdConference) + return &dto.OkResponse{ + Code: http.StatusCreated, + Data: response, + } + }) +} + +func DeleteConference(service conference.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.DeleteConferenceRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + err := service.DeleteConference(request.ID) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: nil, + } + }) +} diff --git a/api/handlers/favourites.go b/api/handlers/favourites.go index 502cb43..d5fe8c3 100644 --- a/api/handlers/favourites.go +++ b/api/handlers/favourites.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "strconv" "github.com/LMBishop/confplanner/api/dto" "github.com/LMBishop/confplanner/pkg/favourites" @@ -16,7 +17,7 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc { return err } - if request.GUID == nil && request.ID == nil { + if request.GUID == nil && request.EventID == nil { return &dto.ErrorResponse{ Code: http.StatusBadRequest, Message: "One of event GUID or event ID must be specified", @@ -34,7 +35,7 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc { } } - createdFavourite, err := service.CreateFavouriteForUser(session.UserID, uuid, request.ID) + createdFavourite, err := service.CreateFavouriteForUser(session.UserID, uuid, request.EventID, request.ConferenceID) if err != nil { return err } @@ -50,9 +51,17 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc { func GetFavourites(service favourites.Service) http.HandlerFunc { return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + conferenceID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + return &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Bad conference ID", + } + } + session := r.Context().Value("session").(*session.UserSession) - favourites, err := service.GetFavouritesForUser(session.UserID) + favourites, err := service.GetFavouritesForUserConference(session.UserID, int32(conferenceID)) if err != nil { return err } @@ -79,7 +88,7 @@ func DeleteFavourite(service favourites.Service) http.HandlerFunc { return err } - if request.GUID == nil && request.ID == nil { + if request.GUID == nil && request.EventID == nil { return &dto.ErrorResponse{ Code: http.StatusBadRequest, Message: "One of event GUID or event ID must be specified", @@ -96,7 +105,7 @@ func DeleteFavourite(service favourites.Service) http.HandlerFunc { } } - err = service.DeleteFavouriteForUserByEventDetails(session.UserID, uuid, request.ID) + err = service.DeleteFavouriteForUserByEventDetails(session.UserID, uuid, request.EventID, request.ConferenceID) if err != nil { if err == favourites.ErrNotFound { return &dto.ErrorResponse{ diff --git a/api/handlers/schedule.go b/api/handlers/schedule.go deleted file mode 100644 index 061e6f9..0000000 --- a/api/handlers/schedule.go +++ /dev/null @@ -1,26 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/LMBishop/confplanner/api/dto" - "github.com/LMBishop/confplanner/pkg/schedule" - "github.com/golang-cz/nilslice" -) - -func GetSchedule(service schedule.Service) http.HandlerFunc { - return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { - schedule, lastUpdated, err := service.GetSchedule() - if err != nil { - return err - } - - return &dto.OkResponse{ - Code: http.StatusOK, - Data: &dto.GetScheduleResponse{ - Schedule: nilslice.Initialize(*schedule), - LastUpdated: lastUpdated, - }, - } - }) -} diff --git a/api/middleware/admin.go b/api/middleware/admin.go new file mode 100644 index 0000000..fd43cd6 --- /dev/null +++ b/api/middleware/admin.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/session" + "github.com/LMBishop/confplanner/pkg/user" +) + +func MustAuthoriseAdmin(service user.Service, store session.Service) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session.UserSession) + + if !session.Admin { + dto.WriteDto(w, r, &dto.ErrorResponse{ + Code: http.StatusForbidden, + Message: "Forbidden", + }) + return + } + + next(w, r) + } + } +} diff --git a/api/middleware/auth.go b/api/middleware/auth.go index eb362b0..438a8a1 100644 --- a/api/middleware/auth.go +++ b/api/middleware/auth.go @@ -3,7 +3,9 @@ package middleware import ( "context" "errors" + "fmt" "net/http" + "strings" "github.com/LMBishop/confplanner/api/dto" "github.com/LMBishop/confplanner/pkg/session" @@ -13,15 +15,17 @@ import ( func MustAuthenticate(service user.Service, store session.Service) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var sessionToken string - for _, cookie := range r.Cookies() { - if cookie.Name == "confplanner_session" { - sessionToken = cookie.Value - break - } + authHeader := r.Header.Get("Authorization") + token, err := extractBearerToken(authHeader) + if err != nil { + dto.WriteDto(w, r, &dto.ErrorResponse{ + Code: http.StatusUnauthorized, + Message: "Unauthorized", + }) + return } - s := store.GetByToken(sessionToken) + s := store.GetByToken(token) if s == nil { dto.WriteDto(w, r, &dto.ErrorResponse{ Code: http.StatusUnauthorized, @@ -30,7 +34,7 @@ func MustAuthenticate(service user.Service, store session.Service) func(http.Han return } - _, err := service.GetUserByID(s.UserID) + u, err := service.GetUserByID(s.UserID) if err != nil { if errors.Is(err, user.ErrUserNotFound) { store.Destroy(s.SessionID) @@ -44,9 +48,27 @@ func MustAuthenticate(service user.Service, store session.Service) func(http.Han return } + s.Username = u.Username + s.Admin = u.Admin + ctx := context.WithValue(r.Context(), "session", s) next(w, r.WithContext(ctx)) } } } + +func extractBearerToken(header string) (string, error) { + const prefix = "Bearer " + if header == "" { + return "", fmt.Errorf("authorization header missing") + } + if !strings.HasPrefix(header, prefix) { + return "", fmt.Errorf("invalid authorization scheme") + } + token := strings.TrimSpace(header[len(prefix):]) + if token == "" { + return "", fmt.Errorf("token is empty") + } + return token, nil +} diff --git a/api/router.go b/api/router.go index 38e67e6..af52785 100644 --- a/api/router.go +++ b/api/router.go @@ -7,9 +7,9 @@ import ( "github.com/LMBishop/confplanner/api/middleware" "github.com/LMBishop/confplanner/pkg/auth" "github.com/LMBishop/confplanner/pkg/calendar" + "github.com/LMBishop/confplanner/pkg/conference" "github.com/LMBishop/confplanner/pkg/favourites" "github.com/LMBishop/confplanner/pkg/ical" - "github.com/LMBishop/confplanner/pkg/schedule" "github.com/LMBishop/confplanner/pkg/session" "github.com/LMBishop/confplanner/pkg/user" ) @@ -17,7 +17,7 @@ import ( type ApiServices struct { UserService user.Service FavouritesService favourites.Service - ScheduleService schedule.Service + ConferenceService conference.Service CalendarService calendar.Service IcalService ical.Service SessionService session.Service @@ -26,6 +26,7 @@ type ApiServices struct { func NewServer(apiServices ApiServices, baseURL string) *http.ServeMux { mustAuthenticate := middleware.MustAuthenticate(apiServices.UserService, apiServices.SessionService) + admin := middleware.MustAuthoriseAdmin(apiServices.UserService, apiServices.SessionService) mux := http.NewServeMux() @@ -34,12 +35,15 @@ func NewServer(apiServices ApiServices, baseURL string) *http.ServeMux { mux.HandleFunc("POST /login/{provider}", handlers.Login(apiServices.AuthService, apiServices.SessionService)) mux.HandleFunc("POST /logout", mustAuthenticate(handlers.Logout(apiServices.SessionService))) - mux.HandleFunc("GET /favourites", mustAuthenticate(handlers.GetFavourites(apiServices.FavouritesService))) + mux.HandleFunc("GET /conference", mustAuthenticate(handlers.GetConferences(apiServices.ConferenceService))) + mux.HandleFunc("GET /conference/{id}", mustAuthenticate(handlers.GetSchedule(apiServices.ConferenceService))) + mux.HandleFunc("POST /conference", mustAuthenticate(admin(handlers.CreateConference(apiServices.ConferenceService)))) + mux.HandleFunc("DELETE /conference", mustAuthenticate(admin(handlers.DeleteConference(apiServices.ConferenceService)))) + + mux.HandleFunc("GET /favourites/{id}", mustAuthenticate(handlers.GetFavourites(apiServices.FavouritesService))) mux.HandleFunc("POST /favourites", mustAuthenticate(handlers.CreateFavourite(apiServices.FavouritesService))) mux.HandleFunc("DELETE /favourites", mustAuthenticate(handlers.DeleteFavourite(apiServices.FavouritesService))) - mux.HandleFunc("GET /schedule", mustAuthenticate(handlers.GetSchedule(apiServices.ScheduleService))) - mux.HandleFunc("GET /calendar", mustAuthenticate(handlers.GetCalendar(apiServices.CalendarService, baseURL))) mux.HandleFunc("POST /calendar", mustAuthenticate(handlers.CreateCalendar(apiServices.CalendarService, baseURL))) mux.HandleFunc("DELETE /calendar", mustAuthenticate(handlers.DeleteCalendar(apiServices.CalendarService))) -- cgit v1.2.3-70-g09d2