diff --git a/config.go b/config.go index ffc68e3..baeeaf7 100755 --- a/config.go +++ b/config.go @@ -23,6 +23,11 @@ func ReadConfig() (*viper.Viper, error) { "enabled": true, "listen": "0.0.0.0:8081", }, + "publicweb": map[string]any{ + "enabled": false, + "listen": "0.0.0.0:8082", + "root_dir": "public_themes/default", + }, }, "identification": map[string]any{ "sessions": map[string]any{ diff --git a/main.go b/main.go index 9236bad..350f072 100755 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "git.coopgo.io/coopgo-apps/parcoursmob/core/application" "git.coopgo.io/coopgo-apps/parcoursmob/renderer" "git.coopgo.io/coopgo-apps/parcoursmob/servers/mcp" + "git.coopgo.io/coopgo-apps/parcoursmob/servers/publicweb" "git.coopgo.io/coopgo-apps/parcoursmob/servers/web" "git.coopgo.io/coopgo-apps/parcoursmob/services" "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/identification" @@ -24,9 +25,10 @@ func main() { } var ( - dev_env = cfg.GetBool("dev_env") - webEnabled = cfg.GetBool("server.web.enabled") - mcpEnabled = cfg.GetBool("server.mcp.enabled") + dev_env = cfg.GetBool("dev_env") + webEnabled = cfg.GetBool("server.web.enabled") + mcpEnabled = cfg.GetBool("server.mcp.enabled") + publicwebEnabled = cfg.GetBool("server.publicweb.enabled") ) if dev_env { @@ -80,5 +82,13 @@ func main() { }() } + if publicwebEnabled { + wg.Add(1) + go func() { + defer wg.Done() + publicweb.Run(cfg, svc, applicationHandler, kv, filestorage) + }() + } + wg.Wait() } diff --git a/servers/publicweb/journeys.go b/servers/publicweb/journeys.go new file mode 100644 index 0000000..481cb73 --- /dev/null +++ b/servers/publicweb/journeys.go @@ -0,0 +1,142 @@ +package publicweb + +import ( + "net/http" + "time" + + "github.com/paulmach/orb/geojson" + "github.com/rs/zerolog/log" +) + +// JourneySearchResponse represents the search results for hydration +type JourneySearchResponse struct { + Searched bool `json:"searched"` + DepartureDate string `json:"departure_date,omitempty"` + DepartureTime string `json:"departure_time,omitempty"` + Departure any `json:"departure,omitempty"` + Destination any `json:"destination,omitempty"` + Error string `json:"error,omitempty"` + Results struct { + SolidarityDrivers struct { + Number int `json:"number"` + } `json:"solidarity_drivers"` + OrganizedCarpools struct { + Number int `json:"number"` + } `json:"organized_carpools"` + Carpools struct { + Number int `json:"number"` + Results any `json:"results,omitempty"` + } `json:"carpools"` + PublicTransit struct { + Number int `json:"number"` + Results any `json:"results,omitempty"` + } `json:"public_transit"` + Vehicles struct { + Number int `json:"number"` + Results any `json:"results,omitempty"` + } `json:"vehicles"` + LocalSolutions struct { + Number int `json:"number"` + Results any `json:"results,omitempty"` + } `json:"local_solutions"` + } `json:"results"` +} + +// journeySearchDataProvider provides data for the journey search page +func (s *PublicWebServer) journeySearchDataProvider(r *http.Request) (any, error) { + if err := r.ParseForm(); err != nil { + log.Error().Err(err).Msg("error parsing form") + return JourneySearchResponse{Error: "invalid request"}, nil + } + + departureDate := r.FormValue("departuredate") + departureTime := r.FormValue("departuretime") + departure := r.FormValue("departure") + destination := r.FormValue("destination") + + response := JourneySearchResponse{ + DepartureDate: departureDate, + DepartureTime: departureTime, + } + + // If no search parameters, return empty response + if departure == "" || destination == "" || departureDate == "" || departureTime == "" { + return response, nil + } + + // Parse timezone and datetime + locTime, err := time.LoadLocation("Europe/Paris") + if err != nil { + log.Error().Err(err).Msg("timezone error") + response.Error = "internal error" + return response, nil + } + + departureDateTime, err := time.ParseInLocation("2006-01-02 15:04", departureDate+" "+departureTime, locTime) + if err != nil { + log.Error().Err(err).Msg("error parsing datetime") + response.Error = "invalid date/time format" + return response, nil + } + + // Parse departure location + departureGeo, err := geojson.UnmarshalFeature([]byte(departure)) + if err != nil { + log.Error().Err(err).Msg("error unmarshalling departure") + response.Error = "invalid departure location" + return response, nil + } + response.Departure = departureGeo + + // Parse destination location + destinationGeo, err := geojson.UnmarshalFeature([]byte(destination)) + if err != nil { + log.Error().Err(err).Msg("error unmarshalling destination") + response.Error = "invalid destination location" + return response, nil + } + response.Destination = destinationGeo + + // Call business logic + result, err := s.applicationHandler.SearchJourneys( + r.Context(), + departureDateTime, + departureGeo, + destinationGeo, + "", // passengerID + "", // solidarityTransportExcludeDriver + "", // solidarityExcludeGroupId + nil, // options - use defaults + ) + if err != nil { + log.Error().Err(err).Msg("error in journey search") + response.Error = "search failed" + return response, nil + } + + response.Searched = result.Searched + + // Solidarity drivers + response.Results.SolidarityDrivers.Number = len(result.DriverJourneys) + + // Organized carpools + response.Results.OrganizedCarpools.Number = len(result.OrganizedCarpools) + + // Carpools (from external operators like Movici) + response.Results.Carpools.Number = len(result.CarpoolResults) + response.Results.Carpools.Results = result.CarpoolResults + + // Public transit + response.Results.PublicTransit.Number = len(result.TransitResults) + response.Results.PublicTransit.Results = result.TransitResults + + // Fleet vehicles + response.Results.Vehicles.Number = len(result.VehicleResults) + response.Results.Vehicles.Results = result.VehicleResults + + // Knowledge base / local solutions + response.Results.LocalSolutions.Number = len(result.KnowledgeBaseResults) + response.Results.LocalSolutions.Results = result.KnowledgeBaseResults + + return response, nil +} diff --git a/servers/publicweb/publicweb.go b/servers/publicweb/publicweb.go new file mode 100644 index 0000000..008cc5d --- /dev/null +++ b/servers/publicweb/publicweb.go @@ -0,0 +1,191 @@ +package publicweb + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + + "git.coopgo.io/coopgo-apps/parcoursmob/core/application" + cache "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/storage" + "git.coopgo.io/coopgo-apps/parcoursmob/services" +) + +// DataProvider returns data to hydrate a page +type DataProvider func(r *http.Request) (any, error) + +// DynamicRoute defines a route with its HTML file and data provider +type DynamicRoute struct { + HTMLFile string + DataProvider DataProvider +} + +// Regex to find the placeholder script tag +var dynamicDataRegex = regexp.MustCompile(`\s*`) + +type PublicWebServer struct { + cfg *viper.Viper + services *services.ServicesHandler + kv cache.KVHandler + filestorage cache.FileStorage + applicationHandler *application.ApplicationHandler + rootDir string + dynamicRoutes map[string]DynamicRoute +} + +func Run( + cfg *viper.Viper, + svc *services.ServicesHandler, + applicationHandler *application.ApplicationHandler, + kv cache.KVHandler, + filestorage cache.FileStorage, +) { + address := cfg.GetString("server.publicweb.listen") + rootDir := cfg.GetString("server.publicweb.root_dir") + serviceName := cfg.GetString("service_name") + + server := &PublicWebServer{ + cfg: cfg, + services: svc, + kv: kv, + filestorage: filestorage, + applicationHandler: applicationHandler, + rootDir: rootDir, + dynamicRoutes: make(map[string]DynamicRoute), + } + + server.registerDynamicRoutes() + + r := mux.NewRouter() + + r.HandleFunc("/health", server.healthHandler).Methods("GET") + + for pattern := range server.dynamicRoutes { + r.HandleFunc(pattern, server.dynamicHandler).Methods("GET", "POST") + } + + r.PathPrefix("/").Handler(server.fileServerHandler()) + + srv := &http.Server{ + Handler: r, + Addr: address, + WriteTimeout: 30 * time.Second, + ReadTimeout: 15 * time.Second, + } + + log.Info(). + Str("service_name", serviceName). + Str("address", address). + Str("root_dir", rootDir). + Msg("Running Public Web HTTP server") + + err := srv.ListenAndServe() + log.Error().Err(err).Msg("Public Web server error") +} + +func (s *PublicWebServer) registerDynamicRoutes() { + s.RegisterDynamicRoute("/recherche/", "recherche/index.html", s.journeySearchDataProvider) +} + +func (s *PublicWebServer) RegisterDynamicRoute(pattern, htmlFile string, provider DataProvider) { + s.dynamicRoutes[pattern] = DynamicRoute{ + HTMLFile: htmlFile, + DataProvider: provider, + } +} + +func (s *PublicWebServer) dynamicHandler(w http.ResponseWriter, r *http.Request) { + route := mux.CurrentRoute(r) + pattern, _ := route.GetPathTemplate() + + dynRoute, exists := s.dynamicRoutes[pattern] + if !exists { + http.NotFound(w, r) + return + } + + data, err := dynRoute.DataProvider(r) + if err != nil { + log.Error().Err(err).Str("route", pattern).Msg("Error getting data") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if err := s.hydrate(w, dynRoute.HTMLFile, data); err != nil { + http.NotFound(w, r) + } +} + +// hydrate reads an HTML file and injects JSON data into +func (s *PublicWebServer) hydrate(w http.ResponseWriter, htmlFile string, data any) error { + htmlPath := filepath.Join(s.rootDir, htmlFile) + htmlContent, err := os.ReadFile(htmlPath) + if err != nil { + log.Error().Err(err).Str("file", htmlPath).Msg("Error reading HTML file") + return err + } + + jsonData, err := json.Marshal(data) + if err != nil { + log.Error().Err(err).Msg("Error marshaling data to JSON") + return err + } + + // Replace the placeholder with a script that assigns data to window.__PARCOURSMOB_DATA__ + replacement := []byte(``) + modifiedHTML := dynamicDataRegex.ReplaceAll(htmlContent, replacement) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(modifiedHTML) + return nil +} + +func (s *PublicWebServer) fileServerHandler() http.Handler { + fs := http.FileServer(http.Dir(s.rootDir)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := filepath.Join(s.rootDir, r.URL.Path) + + if _, err := os.Stat(path); os.IsNotExist(err) { + if filepath.Ext(path) == "" { + if idx := filepath.Join(path, "index.html"); fileExists(idx) { + http.ServeFile(w, r, idx) + return + } + if idx := filepath.Join(s.rootDir, "index.html"); fileExists(idx) { + http.ServeFile(w, r, idx) + return + } + } + http.NotFound(w, r) + return + } + + if info, _ := os.Stat(path); info != nil && info.IsDir() { + if idx := filepath.Join(path, "index.html"); fileExists(idx) && strings.HasSuffix(r.URL.Path, "/") { + http.ServeFile(w, r, idx) + return + } + } + + fs.ServeHTTP(w, r) + }) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func (s *PublicWebServer) healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"healthy"}`)) +}