diff --git a/cmd/build.go b/cmd/build.go index 0c7cd6f..6568e5f 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -17,75 +17,120 @@ package cmd import ( "fmt" - "os" + "net/http" + "strings" + "time" - "github.com/evanoberholster/timezoneLookup" + "github.com/evanoberholster/timezoneLookup/v2" + "github.com/noandrea/geo2tz/v2/helpers" + "github.com/noandrea/geo2tz/v2/web" "github.com/spf13/cobra" ) -// buildCmd represents the build command -var buildCmd = &cobra.Command{ - Use: "build", - Short: "Build the location database", - Long: `The commands replicates the functionality of the evanoberholster/timezoneLookup timezone command`, - Run: build, -} +const ( + // geo data url + LatestReleaseURL = "https://github.com/evansiroky/timezone-boundary-builder/releases/latest" + TZZipFile = "tzdata/timezone.zip" +) var ( - // geo data url - GeoDataURL = "https://github.com/evansiroky/timezone-boundary-builder/releases/download/2022b/timezones-with-oceans.geojson.zip" // cli parameters. - snappy bool - jsonFilename string - dbFilename string + cacheFile string + geoDataURL string ) +// buildCmd represents the build command +var buildCmd = &cobra.Command{ + Use: "build", + Short: "Build the location database for a specific version", + Example: `geo2tz build 2023d`, + Long: `The commands replicates the functionality of the evanoberholster/timezoneLookup timezone command`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + tzVersion := web.NewTzRelease(args[0]) + return update(tzVersion, cacheFile, web.Settings.Tz.DatabaseName) + }, +} + +// buildCmd represents the build command +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update the location database by downloading the latest version", + RunE: func(cmd *cobra.Command, args []string) error { + tzVersion, err := getLatest() + if err != nil { + return err + } + return update(tzVersion, cacheFile, web.Settings.Tz.DatabaseName) + }, +} + func init() { rootCmd.AddCommand(buildCmd) - buildCmd.Flags().StringVar(&dbFilename, "db", "timezone", "Destination database filename") - buildCmd.Flags().BoolVar(&snappy, "snappy", true, "Use Snappy compression (true/false)") - buildCmd.Flags().StringVar(&jsonFilename, "json", "combined-with-oceans.json", "GEOJSON Filename") + buildCmd.Flags().StringVar(&cacheFile, "cache", TZZipFile, "Temporary cache filename") + buildCmd.Flags().StringVar(&web.Settings.Tz.DatabaseName, "db", web.TZDBFile, "Destination database filename") + buildCmd.Flags().StringVar(&web.Settings.Tz.VersionFile, "version-file", web.TZVersionFile, "Version file") + rootCmd.AddCommand(updateCmd) + updateCmd.Flags().StringVar(&geoDataURL, "geo-data-url", "", "URL to download geo data from") + updateCmd.Flags().StringVar(&cacheFile, "cache", TZZipFile, "Temporary cache filename") + updateCmd.Flags().StringVar(&web.Settings.Tz.DatabaseName, "db", web.TZDBFile, "Destination database filename") + updateCmd.Flags().StringVar(&web.Settings.Tz.VersionFile, "version-file", web.TZVersionFile, "Version file") } -func build(*cobra.Command, []string) { - if dbFilename == "" || jsonFilename == "" { - fmt.Printf(`Options: - -snappy=true Use Snappy compression - -json=filename GEOJSON filename - -db=filename Database destination -`) +func update(release web.TzRelease, zipFile, dbFile string) (err error) { + // remove old file + if err = helpers.DeleteQuietly(zipFile, dbFile); err != nil { return } - tz := timezoneLookup.MemoryStorage(snappy, dbFilename) - - if !fileExists(jsonFilename) { - fmt.Printf("json file %v does not exists, will try to download from the source", jsonFilename) + var ( + tzc timezoneLookup.Timezonecache + total int + ) + fmt.Printf("building database %s v%s from %s\n", dbFile, release.Version, release.GeoDataURL) + if err = timezoneLookup.ImportZipFile(zipFile, release.GeoDataURL, func(tz timezoneLookup.Timezone) error { + total += len(tz.Polygons) + tzc.AddTimezone(tz) + return nil + }); err != nil { return } - - if jsonFilename != "" { - err := tz.CreateTimezones(jsonFilename) - if err != nil { - fmt.Println(err) - return - } - } else { - fmt.Println(`"--json" No GeoJSON source file specified`) + if err = tzc.Save(dbFile); err != nil { return } + tzc.Close() + fmt.Println("polygons added:", total) + fmt.Println("saved timezone data to:", dbFile) - tz.Close() - + // remove tmp file + if err = helpers.DeleteQuietly(cacheFile); err != nil { + return + } + err = helpers.SaveJSON(release, web.Settings.Tz.VersionFile) + return } -func fileExists(filePath string) bool { - f, err := os.Stat(filePath) +func getLatest() (web.TzRelease, error) { + // create http client + client := &http.Client{ + Timeout: 1 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // don't follow redirects + return http.ErrUseLastResponse + }, + } + r, err := client.Head(LatestReleaseURL) if err != nil { - return false + err = fmt.Errorf("failed to get release url: %w", err) + return web.TzRelease{}, err } - if f.IsDir() { - return false + defer r.Body.Close() + // get the tag name + releaseURL, err := r.Location() + if err != nil { + err = fmt.Errorf("failed to get release url: %w", err) + return web.TzRelease{}, err } - return true + v := web.NewTzRelease(releaseURL.Path[strings.LastIndex(releaseURL.Path, "/")+1:]) + return v, nil } diff --git a/cmd/root.go b/cmd/root.go index b586cb7..c5a7ea1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,15 +5,14 @@ import ( "log" "strings" + "github.com/noandrea/geo2tz/v2/web" "github.com/spf13/cobra" "github.com/spf13/viper" - - "github.com/noandrea/geo2tz/v2/server" ) var cfgFile string var debug bool -var settings server.ConfigSchema +var settings web.ConfigSchema // rootCmd represents the base command when called without any subcommands. var rootCmd = &cobra.Command{ @@ -50,7 +49,7 @@ func initConfig() error { viper.AddConfigPath("/etc/geo2tz") viper.SetConfigName("config") } - server.Defaults() + web.Defaults() viper.SetEnvPrefix("GEO2TZ") viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.AutomaticEnv() // read in environment variables that match diff --git a/cmd/start.go b/cmd/start.go index 75d13d6..39eefd7 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" - "github.com/noandrea/geo2tz/v2/server" + "github.com/noandrea/geo2tz/v2/web" ) // startCmd represents the start command. @@ -33,10 +33,15 @@ func start(*cobra.Command, []string) { \_____|\___|\___/____|\__/___| version %s `, rootCmd.Version) // Start server + server, err := web.NewServer(settings) + if err != nil { + log.Println("Error creating the server ", err) + os.Exit(1) + } go func() { - if err := server.Start(settings); err != nil { + if err = server.Start(); err != nil { log.Println("Error starting the server ", err) - return + os.Exit(1) } }() @@ -46,7 +51,7 @@ func start(*cobra.Command, []string) { quit := make(chan os.Signal, signalChannelLength) signal.Notify(quit, os.Interrupt) <-quit - if err := server.Teardown(); err != nil { + if err = server.Teardown(); err != nil { log.Println("error stopping server: ", err) } fmt.Print("Goodbye") diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..2a6a6ca --- /dev/null +++ b/coverage.out @@ -0,0 +1,42 @@ +mode: atomic +github.com/noandrea/geo2tz/v2/web/config.go:39.17,47.2 5 0 +github.com/noandrea/geo2tz/v2/web/config.go:50.46,53.2 1 0 +github.com/noandrea/geo2tz/v2/web/server.go:30.39,33.2 2 7 +github.com/noandrea/geo2tz/v2/web/server.go:36.62,38.2 1 2 +github.com/noandrea/geo2tz/v2/web/server.go:49.37,51.2 1 0 +github.com/noandrea/geo2tz/v2/web/server.go:53.46,57.24 4 0 +github.com/noandrea/geo2tz/v2/web/server.go:57.24,59.3 1 0 +github.com/noandrea/geo2tz/v2/web/server.go:60.2,60.8 1 0 +github.com/noandrea/geo2tz/v2/web/server.go:63.54,69.16 5 4 +github.com/noandrea/geo2tz/v2/web/server.go:69.16,72.3 2 1 +github.com/noandrea/geo2tz/v2/web/server.go:73.2,75.43 2 3 +github.com/noandrea/geo2tz/v2/web/server.go:75.43,78.3 2 0 +github.com/noandrea/geo2tz/v2/web/server.go:81.2,82.40 2 3 +github.com/noandrea/geo2tz/v2/web/server.go:82.40,85.3 2 0 +github.com/noandrea/geo2tz/v2/web/server.go:85.8,87.3 1 3 +github.com/noandrea/geo2tz/v2/web/server.go:89.2,96.16 6 3 +github.com/noandrea/geo2tz/v2/web/server.go:96.16,99.3 2 1 +github.com/noandrea/geo2tz/v2/web/server.go:100.2,101.68 2 2 +github.com/noandrea/geo2tz/v2/web/server.go:101.68,104.3 2 0 +github.com/noandrea/geo2tz/v2/web/server.go:107.2,110.21 3 2 +github.com/noandrea/geo2tz/v2/web/server.go:113.61,115.24 1 4 +github.com/noandrea/geo2tz/v2/web/server.go:115.24,117.50 2 0 +github.com/noandrea/geo2tz/v2/web/server.go:117.50,120.4 2 0 +github.com/noandrea/geo2tz/v2/web/server.go:123.2,124.16 2 4 +github.com/noandrea/geo2tz/v2/web/server.go:124.16,127.3 2 2 +github.com/noandrea/geo2tz/v2/web/server.go:129.2,130.16 2 2 +github.com/noandrea/geo2tz/v2/web/server.go:130.16,133.3 2 1 +github.com/noandrea/geo2tz/v2/web/server.go:136.2,137.16 2 1 +github.com/noandrea/geo2tz/v2/web/server.go:137.16,140.3 2 0 +github.com/noandrea/geo2tz/v2/web/server.go:141.2,141.97 1 1 +github.com/noandrea/geo2tz/v2/web/server.go:144.61,146.2 1 1 +github.com/noandrea/geo2tz/v2/web/server.go:149.57,150.34 1 19 +github.com/noandrea/geo2tz/v2/web/server.go:150.34,152.3 1 2 +github.com/noandrea/geo2tz/v2/web/server.go:153.2,154.16 2 17 +github.com/noandrea/geo2tz/v2/web/server.go:154.16,156.3 1 2 +github.com/noandrea/geo2tz/v2/web/server.go:157.2,157.14 1 15 +github.com/noandrea/geo2tz/v2/web/server.go:158.16,159.24 1 8 +github.com/noandrea/geo2tz/v2/web/server.go:159.24,161.4 1 4 +github.com/noandrea/geo2tz/v2/web/server.go:162.17,163.26 1 7 +github.com/noandrea/geo2tz/v2/web/server.go:163.26,165.4 1 4 +github.com/noandrea/geo2tz/v2/web/server.go:167.2,167.15 1 7 diff --git a/helpers/helpers.go b/helpers/helpers.go new file mode 100644 index 0000000..93e466f --- /dev/null +++ b/helpers/helpers.go @@ -0,0 +1,56 @@ +package helpers + +import ( + "encoding/json" + "fmt" + "os" +) + +func SaveJSON(data any, toFile string) (err error) { + // write the version file as json, overwriting the old one + jsonString, err := json.MarshalIndent(data, "", " ") + if err != nil { + return + } + if err = os.WriteFile(toFile, jsonString, os.ModePerm); err != nil { + return + } + return +} + +func LoadJSON(fromFile string, data any) (err error) { + // read the version file + jsonFile, err := os.Open(fromFile) + if err != nil { + return + } + defer jsonFile.Close() + jsonParser := json.NewDecoder(jsonFile) + if err = jsonParser.Decode(data); err != nil { + return + } + return +} + +func DeleteQuietly(filePath ...string) (err error) { + for _, filePath := range filePath { + if xErr := FileExists(filePath); xErr == nil { + fmt.Println("removing file", filePath) + if err = os.Remove(filePath); err != nil { + return + } + } + } + return +} + +func FileExists(filePath string) error { + f, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("file does not exist: %s", filePath) + } + if f.IsDir() { + return fmt.Errorf("file is a directory: %s", filePath) + } + return nil +} diff --git a/main.go b/main.go index fd67e77..939856a 100644 --- a/main.go +++ b/main.go @@ -29,12 +29,16 @@ import ( ) // Version hold the version of the program. -var Version = "0.0.0" +var ( + version = "dev" + commit = "unknown" + date = "unknown" + builtBy = "unknown" +) func main() { - if err := cmd.Execute(Version); err != nil { + if err := cmd.Execute(version); err != nil { fmt.Println(err) os.Exit(1) } - } diff --git a/scripts/update-tzdata.sh b/scripts/update-tzdata.sh deleted file mode 100755 index d9a21ac..0000000 --- a/scripts/update-tzdata.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -VERSION=2024a -WORKDIR=dist - -pushd $WORKDIR -echo download timezones -curl -LO https://github.com/evansiroky/timezone-boundary-builder/releases/download/$VERSION/timezones-with-oceans.geojson.zip -unzip timezones-with-oceans.geojson.zip -./geo2tz build --json "combined-with-oceans.json" --db=timezone -echo "https://github.com/evansiroky/timezone-boundary-builder" > SOURCE -echo "tzdata v$VERSION" >> SOURCE - -popd -mv dist/timezone.snap.json tzdata/timezone.snap.json -mv dist/SOURCE tzdata/SOURCE diff --git a/server/server.go b/server/server.go deleted file mode 100644 index 4bc3003..0000000 --- a/server/server.go +++ /dev/null @@ -1,143 +0,0 @@ -package server - -import ( - "context" - "crypto/subtle" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/evanoberholster/timezoneLookup" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "golang.org/x/crypto/blake2b" -) - -// constant valuses for lat / lon -const ( - Latitude = "lat" - Longitude = "lon" - compareEquals = 1 - teardownTimeout = 10 * time.Second -) - -var ( - tz timezoneLookup.TimezoneInterface - e *echo.Echo -) - -// hash calculate the hash of a string -func hash(data ...interface{}) []byte { - hash := blake2b.Sum256([]byte(fmt.Sprint(data...))) - return hash[:] -} - -// isEq check if the hash of the second value is equals to the first value -func isEq(expectedTokenHash []byte, actualToken string) bool { - return subtle.ConstantTimeCompare(expectedTokenHash, hash(actualToken)) == compareEquals -} - -// Start starts the web server -func Start(config ConfigSchema) (err error) { - // echo start - e = echo.New() - // open the database - tz, err = timezoneLookup.LoadTimezones( - timezoneLookup.Config{ - DatabaseType: "memory", // memory or boltdb - DatabaseName: config.Tz.DatabaseName, // Name without suffix - Snappy: config.Tz.Snappy, - }) - if err != nil { - return - } - - // check token authorization - hashedToken := hash(config.Web.AuthTokenValue) - authEnabled := false - if len(config.Web.AuthTokenValue) > 0 { - e.Logger.Info("Authorization enabled") - authEnabled = true - } else { - e.Logger.Info("Authorization disabled") - } - - e.HideBanner = true - e.Use(middleware.CORS()) - e.Use(middleware.Logger()) - e.Use(middleware.Recover()) - // logger - e.GET("/tz/:lat/:lon", func(c echo.Context) (err error) { - // token verification - if authEnabled { - requestToken := c.QueryParam(config.Web.AuthTokenParamName) - if !isEq(hashedToken, requestToken) { - e.Logger.Errorf("request unhautorized, invalid token: %v", requestToken) - return c.JSON(http.StatusUnauthorized, map[string]interface{}{"message": "unauthorized"}) - } - } - // parse latitude - lat, err := parseCoordinate(c.Param(Latitude), Latitude) - if err != nil { - e.Logger.Errorf("error parsing latitude: %v", err) - return c.JSON(http.StatusBadRequest, map[string]interface{}{"message": fmt.Sprint(err)}) - } - // parse longitude - lon, err := parseCoordinate(c.Param(Longitude), Longitude) - if err != nil { - e.Logger.Errorf("error parsing longitude: %v", err) - return c.JSON(http.StatusBadRequest, map[string]interface{}{"message": fmt.Sprint(err)}) - } - // build coordinates object - coords := timezoneLookup.Coord{ - Lat: lat, - Lon: lon, - } - // query the coordinates - res, err := tz.Query(coords) - if err != nil { - e.Logger.Errorf("error querying the timezoned db: %v", err) - return c.JSON(http.StatusInternalServerError, map[string]interface{}{"message": fmt.Sprint(err)}) - } - return c.JSON(http.StatusOK, map[string]interface{}{"tz": res, "coords": coords}) - }) - err = e.Start(config.Web.ListenAddress) - return -} - -// parseCoordinate parse a string into a coordinate -func parseCoordinate(val, side string) (float32, error) { - if strings.TrimSpace(val) == "" { - return 0, fmt.Errorf("empty coordinates value") - } - c, err := strconv.ParseFloat(val, 32) - if err != nil { - return 0, fmt.Errorf("invalid type for %s, a number is required (eg. 45.3123)", side) - } - switch side { - case Latitude: - if c < -90 || c > 90 { - return 0, fmt.Errorf("%s value %s out of range (-90/+90)", side, val) - } - case Longitude: - if c < -180 || c > 180 { - return 0, fmt.Errorf("%s value %s out of range (-180/+180)", side, val) - } - } - return float32(c), nil -} - -// Teardown gracefully release resources -func Teardown() (err error) { - if tz != nil { - tz.Close() - } - ctx, cancel := context.WithTimeout(context.Background(), teardownTimeout) - defer cancel() - if e != nil { - err = e.Shutdown(ctx) - } - return -} diff --git a/server/server_test.go b/server/server_test.go deleted file mode 100644 index 40c39fd..0000000 --- a/server/server_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package server - -import ( - "fmt" - "reflect" - "testing" -) - -func Test_parseCoordinate(t *testing.T) { - type c struct { - val string - side string - } - tests := []struct { - ll c - want float32 - wantErr bool - }{ - {c{"22", Latitude}, 22, false}, - {c{"78.312", Longitude}, 78.312, false}, - {c{"0x429c9fbe", Longitude}, 0, true}, // 78.312 in hex - {c{"", Longitude}, 0, true}, - {c{" ", Longitude}, 0, true}, - {c{"2e4", Longitude}, 0, true}, - {c{"not a number", Longitude}, 0, true}, - {c{"-90.1", Latitude}, 0, true}, - {c{"90.001", Latitude}, 0, true}, - {c{"-180.1", Longitude}, 0, true}, - {c{"180.001", Longitude}, 0, true}, - } - for _, tt := range tests { - t.Run(fmt.Sprint(tt.ll), func(t *testing.T) { - got, err := parseCoordinate(tt.ll.val, tt.ll.side) - if (err != nil) != tt.wantErr { - t.Errorf("parseCoordinate() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("parseCoordinate() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_hash(t *testing.T) { - tests := []struct { - name string - args []interface{} - want []byte - }{ - { - "one element", - []interface{}{ - "test1", - }, - []byte{229, 104, 55, 204, 215, 163, 141, 103, 149, 211, 10, 194, 171, 99, 236, 204, 140, 43, 87, 18, 137, 166, 45, 196, 6, 187, 98, 118, 126, 136, 176, 108}, - }, - { - "two elements", - []interface{}{ - "test1", - "test2", - }, - []byte{84, 182, 224, 44, 5, 184, 19, 24, 41, 163, 6, 53, 242, 3, 167, 200, 192, 113, 61, 137, 208, 241, 141, 225, 134, 61, 78, 124, 88, 254, 117, 159}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := hash(tt.args...); !reflect.DeepEqual(got, tt.want) { - t.Errorf("hash() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_isEq(t *testing.T) { - type args struct { - expectedTokenHash []byte - actualToken string - } - tests := []struct { - name string - args args - want bool - }{ - { - "PASS: token matches", - args{ - []byte{229, 104, 55, 204, 215, 163, 141, 103, 149, 211, 10, 194, 171, 99, 236, 204, 140, 43, 87, 18, 137, 166, 45, 196, 6, 187, 98, 118, 126, 136, 176, 108}, - "test1", - }, - true, - }, - { - "FAIL: token mismatch", - args{ - []byte{84, 182, 224, 44, 5, 184, 19, 24, 41, 163, 6, 53, 242, 3, 167, 200, 192, 113, 61, 137, 208, 241, 141, 225, 134, 61, 78, 124, 88, 254, 117, 159}, - "test1", - }, - false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := isEq(tt.args.expectedTokenHash, tt.args.actualToken); got != tt.want { - t.Errorf("isEq() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/tzdata/SOURCE b/tzdata/SOURCE deleted file mode 100644 index b930ac7..0000000 --- a/tzdata/SOURCE +++ /dev/null @@ -1,2 +0,0 @@ -https://github.com/evansiroky/timezone-boundary-builder -tzdata v2024a diff --git a/tzdata/timezone.snap.json b/tzdata/timezone.snap.json deleted file mode 100644 index 21cff05..0000000 Binary files a/tzdata/timezone.snap.json and /dev/null differ diff --git a/tzdata/version.json b/tzdata/version.json new file mode 100755 index 0000000..b8eb798 --- /dev/null +++ b/tzdata/version.json @@ -0,0 +1,5 @@ +{ + "version": "2023b", + "url": "https://github.com/evansiroky/timezone-boundary-builder/releases/tag/2023b", + "geo_data_url": "https://github.com/evansiroky/timezone-boundary-builder/releases/download/2023b/timezones-with-oceans.geojson.zip" +} \ No newline at end of file diff --git a/server/config.go b/web/config.go similarity index 53% rename from server/config.go rename to web/config.go index b8848c1..905da6b 100644 --- a/server/config.go +++ b/web/config.go @@ -1,16 +1,37 @@ -package server +package web import ( + "fmt" + "github.com/spf13/viper" ) +const ( + TZDBFile = "tzdata/timezone.db" + TZVersionFile = "tzdata/version.json" + + GeoDataURLTemplate = "https://github.com/evansiroky/timezone-boundary-builder/releases/download/%s/timezones-with-oceans.geojson.zip" + GeoDataReleaseURLTemplate = "https://github.com/evansiroky/timezone-boundary-builder/releases/tag/%s" +) + +type TzRelease struct { + Version string `json:"version"` + URL string `json:"url"` + GeoDataURL string `json:"geo_data_url"` +} + +func NewTzRelease(version string) TzRelease { + return TzRelease{ + Version: version, + URL: fmt.Sprintf(GeoDataReleaseURLTemplate, version), + GeoDataURL: fmt.Sprintf(GeoDataURLTemplate, version), + } +} + // TzSchema configuration type TzSchema struct { - DatabaseName string `mapstructure:"database_name"` - Snappy bool `mapstructure:"snappy"` - DownloadTzData bool `mapstructure:"download_tz_data"` - DownloadTzDataURL string `mapstructure:"download_tz_data_url"` - DownloadTzFilename string `mapstructure:"download_tz_filename"` + DatabaseName string `mapstructure:"database_name"` + VersionFile string `mapstructure:"version_file"` } // WebSchema configuration @@ -30,16 +51,12 @@ type ConfigSchema struct { // Defaults configure defaults func Defaults() { // tz defaults - viper.SetDefault("tz.database_name", "tzdata/timezone") - viper.SetDefault("tz.snappy", true) - viper.SetDefault("tz.download_tz_data", true) - viper.SetDefault("tz.download_tz_data_url", "https://api.github.com/repos/evansiroky/timezone-boundary-builder/releases/latest") - viper.SetDefault("tz.download_tz_filename", "timezones.geojson.zip") + viper.SetDefault("tz.database_name", TZDBFile) + viper.SetDefault("tz.version_file", TZVersionFile) // web viper.SetDefault("web.listen_address", ":2004") viper.SetDefault("web.auth_token_value", "") // GEO2TZ_WEB_AUTH_TOKEN_VALUE="ciao" viper.SetDefault("web.auth_token_param_name", "t") - } // Validate a configuration diff --git a/web/errors.go b/web/errors.go new file mode 100644 index 0000000..e411697 --- /dev/null +++ b/web/errors.go @@ -0,0 +1,10 @@ +package web + +import "errors" + +var ( + ErrorVersionFileNotFound = errors.New("release version file not found") + ErrorVersionFileInvalid = errors.New("release version file invalid") + ErrorDatabaseFileNotFound = errors.New("database file not found") + ErrorDatabaseFileInvalid = errors.New("database file invalid") +) diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..ccbd446 --- /dev/null +++ b/web/server.go @@ -0,0 +1,180 @@ +package web + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/evanoberholster/timezoneLookup/v2" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/noandrea/geo2tz/v2/helpers" + + "golang.org/x/crypto/blake2b" +) + +// constant valuses for lat / lon +const ( + Latitude = "lat" + Longitude = "lon" + compareEquals = 1 + teardownTimeout = 10 * time.Second +) + +// hash calculate the hash of a string +func hash(data ...interface{}) []byte { + hash := blake2b.Sum256([]byte(fmt.Sprint(data...))) + return hash[:] +} + +// isEq check if the hash of the second value is equals to the first value +func isEq(expectedTokenHash []byte, actualToken string) bool { + return subtle.ConstantTimeCompare(expectedTokenHash, hash(actualToken)) == compareEquals +} + +type Server struct { + config ConfigSchema + tzDB timezoneLookup.Timezonecache + tzRelease TzRelease + echo *echo.Echo + authEnabled bool + authHashedToken []byte +} + +func (server *Server) Start() error { + return server.echo.Start(server.config.Web.ListenAddress) +} + +func (server *Server) Teardown() (err error) { + server.tzDB.Close() + ctx, cancel := context.WithTimeout(context.Background(), teardownTimeout) + defer cancel() + if server.echo != nil { + err = server.echo.Shutdown(ctx) + } + return +} + +func NewServer(config ConfigSchema) (*Server, error) { + var server Server + server.config = config + server.echo = echo.New() + // open the database + f, err := os.Open(config.Tz.DatabaseName) + if err != nil { + err = errors.Join(ErrorDatabaseFileNotFound, fmt.Errorf("error opening the timezone database: %w", err)) + return nil, err + } + defer f.Close() + + // load the database + if err = server.tzDB.Load(f); err != nil { + err = errors.Join(ErrorDatabaseFileInvalid, fmt.Errorf("error loading the timezone database: %w", err)) + return nil, err + } + + // check token authorization + server.authHashedToken = hash(config.Web.AuthTokenValue) + if len(config.Web.AuthTokenValue) > 0 { + server.echo.Logger.Info("Authorization enabled") + server.authEnabled = true + } else { + server.echo.Logger.Info("Authorization disabled") + } + + server.echo.HideBanner = true + server.echo.Use(middleware.CORS()) + server.echo.Use(middleware.Logger()) + server.echo.Use(middleware.Recover()) + + // load the release info + if err = helpers.LoadJSON(config.Tz.VersionFile, &server.tzRelease); err != nil { + err = errors.Join(ErrorVersionFileNotFound, err, fmt.Errorf("error loading the timezone release info: %w", err)) + return nil, err + } + + // register routes + server.echo.GET("/tz/:lat/:lon", server.handleTzRequest) + server.echo.GET("/tz/version", server.handleTzVersion) + + return &server, nil +} + +func (server *Server) handleTzRequest(c echo.Context) error { + // token verification + if server.authEnabled { + requestToken := c.QueryParam(server.config.Web.AuthTokenParamName) + if !isEq(server.authHashedToken, requestToken) { + server.echo.Logger.Errorf("request unauthorized, invalid token: %v", requestToken) + return c.JSON(http.StatusUnauthorized, map[string]interface{}{"message": "unauthorized"}) + } + } + // parse latitude + lat, err := parseCoordinate(c.Param(Latitude), Latitude) + if err != nil { + server.echo.Logger.Errorf("error parsing latitude: %v", err) + return c.JSON(http.StatusBadRequest, newErrResponse(err)) + } + // parse longitude + lon, err := parseCoordinate(c.Param(Longitude), Longitude) + if err != nil { + server.echo.Logger.Errorf("error parsing longitude: %v", err) + return c.JSON(http.StatusBadRequest, newErrResponse(err)) + } + + // query the coordinates + res, err := server.tzDB.Search(lat, lon) + if err != nil { + server.echo.Logger.Errorf("error querying the timezone db: %v", err) + return c.JSON(http.StatusInternalServerError, newErrResponse(err)) + } + if res.Name == "" { + notFoundErr := fmt.Errorf("timezone not found for coordinates %f,%f", lat, lon) + server.echo.Logger.Errorf("error querying the timezone db: %v", notFoundErr) + return c.JSON(http.StatusNotFound, newErrResponse(notFoundErr)) + } + + tzr := newTzResponse(res.Name, lat, lon) + return c.JSON(http.StatusOK, tzr) +} + +func newTzResponse(tzName string, lat, lon float64) map[string]any { + return map[string]any{"tz": tzName, "coords": map[string]float64{Latitude: lat, Longitude: lon}} +} + +func newErrResponse(err error) map[string]any { + return map[string]any{"message": err.Error()} +} + +func (server *Server) handleTzVersion(c echo.Context) error { + return c.JSON(http.StatusOK, server.tzRelease) +} + +// parseCoordinate parse a string into a coordinate +func parseCoordinate(val, side string) (float64, error) { + if strings.TrimSpace(val) == "" { + return 0, fmt.Errorf("empty coordinates value") + } + + c, err := strconv.ParseFloat(val, 64) + if err != nil { + return 0, fmt.Errorf("invalid type for %s, a number is required (eg. 45.3123)", side) + } + switch side { + case Latitude: + if c < -90 || c > 90 { + return 0, fmt.Errorf("%s value %s out of range (-90/+90)", side, val) + } + case Longitude: + if c < -180 || c > 180 { + return 0, fmt.Errorf("%s value %s out of range (-180/+180)", side, val) + } + } + return c, nil +} diff --git a/web/server_test.go b/web/server_test.go new file mode 100644 index 0000000..647b5b1 --- /dev/null +++ b/web/server_test.go @@ -0,0 +1,240 @@ +package web + +import ( + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_parseCoordinate(t *testing.T) { + type c struct { + val string + side string + } + tests := []struct { + ll c + want float64 + wantErr bool + }{ + {c{"22", Latitude}, 22, false}, + {c{"78.312", Longitude}, 78.312, false}, + {c{"0x429c9fbe", Longitude}, 0, true}, // 78.312 in hex + {c{"", Longitude}, 0, true}, + {c{" ", Longitude}, 0, true}, + {c{"2e4", Longitude}, 0, true}, + {c{"not a number", Longitude}, 0, true}, + {c{"-90.1", Latitude}, 0, true}, + {c{"90.001", Latitude}, 0, true}, + {c{"-180.1", Longitude}, 0, true}, + {c{"180.001", Longitude}, 0, true}, + {c{"43.42582", Latitude}, 43.42582, false}, + {c{"11.831443", Longitude}, 11.831443, false}, + } + for _, tt := range tests { + t.Run(fmt.Sprint(tt.ll), func(t *testing.T) { + got, err := parseCoordinate(tt.ll.val, tt.ll.side) + if (err != nil) != tt.wantErr { + t.Errorf("parseCoordinate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("parseCoordinate() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_hash(t *testing.T) { + tests := []struct { + name string + args []interface{} + want []byte + }{ + { + "one element", + []interface{}{ + "test1", + }, + []byte{229, 104, 55, 204, 215, 163, 141, 103, 149, 211, 10, 194, 171, 99, 236, 204, 140, 43, 87, 18, 137, 166, 45, 196, 6, 187, 98, 118, 126, 136, 176, 108}, + }, + { + "two elements", + []interface{}{ + "test1", + "test2", + }, + []byte{84, 182, 224, 44, 5, 184, 19, 24, 41, 163, 6, 53, 242, 3, 167, 200, 192, 113, 61, 137, 208, 241, 141, 225, 134, 61, 78, 124, 88, 254, 117, 159}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hash(tt.args...); !reflect.DeepEqual(got, tt.want) { + t.Errorf("hash() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_isEq(t *testing.T) { + type args struct { + expectedTokenHash []byte + actualToken string + } + tests := []struct { + name string + args args + want bool + }{ + { + "PASS: token matches", + args{ + []byte{229, 104, 55, 204, 215, 163, 141, 103, 149, 211, 10, 194, 171, 99, 236, 204, 140, 43, 87, 18, 137, 166, 45, 196, 6, 187, 98, 118, 126, 136, 176, 108}, + "test1", + }, + true, + }, + { + "FAIL: token mismatch", + args{ + []byte{84, 182, 224, 44, 5, 184, 19, 24, 41, 163, 6, 53, 242, 3, 167, 200, 192, 113, 61, 137, 208, 241, 141, 225, 134, 61, 78, 124, 88, 254, 117, 159}, + "test1", + }, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isEq(tt.args.expectedTokenHash, tt.args.actualToken); got != tt.want { + t.Errorf("isEq() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewServer(t *testing.T) { + settings := ConfigSchema{ + Tz: TzSchema{ + VersionFile: "file_not_found.json", + DatabaseName: "../tzdata/timezone.db", + }, + } + _, err := NewServer(settings) + assert.ErrorIs(t, err, ErrorVersionFileNotFound) + + settings = ConfigSchema{ + Tz: TzSchema{ + VersionFile: "../tzdata/version.json", + DatabaseName: "timezone_not_found.db", + }, + } + _, err = NewServer(settings) + assert.ErrorIs(t, err, ErrorDatabaseFileNotFound) +} + +func Test_TzVersion(t *testing.T) { + settings := ConfigSchema{ + Tz: TzSchema{ + VersionFile: "../tzdata/version.json", + DatabaseName: "../tzdata/timezone.db", + }, + } + server, err := NewServer(settings) + assert.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := server.echo.NewContext(req, rec) + c.SetPath("/tz/version") + + versionJSON := `{"version":"2023d","url":"https://github.com/evansiroky/timezone-boundary-builder/releases/tag/2023d","geo_data_url":"https://github.com/evansiroky/timezone-boundary-builder/releases/download/2023d/timezones-with-oceans.geojson.zip"}` + + // Assertions + if assert.NoError(t, server.handleTzVersion(c)) { + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, versionJSON, strings.TrimSpace(rec.Body.String())) + } +} + +func Test_TzRequest(t *testing.T) { + settings := ConfigSchema{ + Tz: TzSchema{ + VersionFile: "../tzdata/version.json", + DatabaseName: "../tzdata/timezone.db", + }, + } + server, err := NewServer(settings) + assert.NoError(t, err) + + tests := []struct { + name string + lat string + lon string + wantCode int + wantReply string + }{ + { + "PASS: valid coordinates", + "51.477811", + "0", + http.StatusOK, + `{"coords":{"lat":51.477811,"lon":0},"tz":"Europe/London"}`, + }, + { + "PASS: valid coordinates", + "43.42582", + "11.831443", + http.StatusOK, + `{"coords":{"lat":43.42582,"lon":11.831443},"tz":"Europe/Rome"}`, + }, + { + "PASS: valid coordinates", + "41.9028", + "12.4964", + http.StatusOK, + `{"coords":{"lat":41.9028,"lon":12.4964},"tz":"Europe/Rome"}`, + }, + { + "FAIL: invalid latitude", + "100", + "11.831443", + http.StatusBadRequest, + `{"message":"lat value 100 out of range (-90/+90)"}`, + }, + { + "FAIL: invalid longitude", + "43.42582", + "200", + http.StatusBadRequest, + `{"message":"lon value 200 out of range (-180/+180)"}`, + }, + { + "FAIL: invalid latitude and longitude", + "100", + "200", + http.StatusBadRequest, + `{"message":"lat value 100 out of range (-90/+90)"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := server.echo.NewContext(req, rec) + c.SetPath("/tz/:lat/:lon") + c.SetParamNames("lat", "lon") + c.SetParamValues(tt.lat, tt.lon) + + // Assertions + if assert.NoError(t, server.handleTzRequest(c)) { + assert.Equal(t, tt.wantCode, rec.Code) + assert.Equal(t, tt.wantReply, strings.TrimSpace(rec.Body.String())) + } + }) + } +}