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(``)
+
+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"}`))
+}