diff --git a/cmd/start.go b/cmd/start.go index 39eefd7..4b0271f 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -32,6 +32,9 @@ func start(*cobra.Command, []string) { | |__| | __/ (_) / /_| |_ / / \_____|\___|\___/____|\__/___| version %s `, rootCmd.Version) + // print example request + log.Printf("example tz request: curl -s 'http://localhost:2004/tz/45.4642/9.1900'\n") + log.Printf("example time request: curl -s 'http://localhost:2004/time/Europe/Rome'\n") // Start server server, err := web.NewServer(settings) if err != nil { diff --git a/db/db.go b/db/db.go index e015c5b..7b38e90 100644 --- a/db/db.go +++ b/db/db.go @@ -1,9 +1,31 @@ package db -import "errors" +import ( + "errors" + "time" +) + +type TzReply struct { + TZ string `json:"tz,omitempty"` + Coords struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + } `json:"coords,omitempty"` +} + +type ZoneReply struct { + TzReply + Local time.Time `json:"local"` + UTC time.Time `json:"utc"` + IsDST bool `json:"is_dst"` + Offset int `json:"offset"` + Zone string `json:"zone"` +} type TzDBIndex interface { - Lookup(lat, lon float64) (string, error) + Lookup(lat, lon float64) (TzReply, error) + LookupZone(lat, lon float64) (ZoneReply, error) + LookupTime(tzID string) (ZoneReply, error) } var ( diff --git a/db/rtree.go b/db/rtree.go index ff4174c..1dcfd0b 100644 --- a/db/rtree.go +++ b/db/rtree.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "time" "archive/zip" @@ -115,6 +116,42 @@ func (g *Geo2TzRTreeIndex) Lookup(lat, lng float64) (tzID string, err error) { return } +func (*Geo2TzRTreeIndex) LookupTime(tzID string) (zr ZoneReply, err error) { + tz, err := time.LoadLocation(tzID) + if err != nil { + err = errors.Join(ErrNotFound, err) + return + } + + local := time.Now().In(tz) + zone, offset := local.Zone() + + zr = ZoneReply{ + Local: local, + UTC: local.UTC(), + IsDST: local.IsDST(), + Offset: offset / 3600, // from seconds to hours + Zone: zone, + } + + return +} + +func (g *Geo2TzRTreeIndex) LookupZone(lat, lng float64) (zr ZoneReply, err error) { + tzID, err := g.Lookup(lat, lng) + if err != nil { + return + } + zr, err = g.LookupTime(tzID) + if err != nil { + return + } + zr.TZ = tzID + zr.Coords.Lat = lat + zr.Coords.Lon = lng + return +} + // isPointInPolygonPIP checks if a point is inside a polygon using the Point in Polygon algorithm func isPointInPolygonPIP(point vertex, polygon polygon) bool { oddNodes := false diff --git a/web/server.go b/web/server.go index ff2ad3a..7bb6f39 100644 --- a/web/server.go +++ b/web/server.go @@ -72,10 +72,20 @@ func NewServer(config ConfigSchema) (*Server, error) { server.tzDB = tzDB // check token authorization - server.authHashedToken = hash(config.Web.AuthTokenValue) if len(config.Web.AuthTokenValue) > 0 { server.echo.Logger.Info("Authorization enabled") - server.authEnabled = true + server.authHashedToken = hash(config.Web.AuthTokenValue) + authMiddleware := middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ + KeyLookup: fmt.Sprintf("query:%s,header:%s", config.Web.AuthTokenParamName, config.Web.AuthTokenParamName), + Validator: func(key string, c echo.Context) (bool, error) { + return isEq(server.authHashedToken, key), nil + }, + ErrorHandler: func(err error, c echo.Context) error { + server.echo.Logger.Errorf("request unauthorized, invalid token", err) + return c.JSON(http.StatusUnauthorized, map[string]interface{}{"message": "unauthorized"}) + }, + }) + server.echo.Use(authMiddleware) } else { server.echo.Logger.Info("Authorization disabled") } @@ -93,20 +103,23 @@ func NewServer(config ConfigSchema) (*Server, error) { // register routes server.echo.GET("/tz/:lat/:lon", server.handleTzRequest) - server.echo.GET("/tz/version", server.handleTzVersion) + server.echo.GET("/tz/version", server.handleTzVersionRequest) + server.echo.GET("/time/:tzID", server.handleTimeRequest) 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"}) - } +func (server *Server) handleTimeRequest(c echo.Context) error { + tzID := c.Param("tzID") + zr, err := server.tzDB.LookupTime(tzID) + if err != nil { + server.echo.Logger.Errorf("error loading timezone %s: %v", tzID, err) + return c.JSON(http.StatusNotFound, newErrResponse(err)) } + return c.JSON(http.StatusOK, zr) +} + +func (server *Server) handleTzRequest(c echo.Context) error { // parse latitude lat, err := parseCoordinate(c.Param(Latitude), Latitude) if err != nil { @@ -119,7 +132,6 @@ func (server *Server) handleTzRequest(c echo.Context) error { server.echo.Logger.Errorf("error parsing longitude: %v", err) return c.JSON(http.StatusBadRequest, newErrResponse(err)) } - // query the coordinates res, err := server.tzDB.Lookup(lat, lon) switch err { @@ -144,7 +156,7 @@ func newErrResponse(err error) map[string]any { return map[string]any{"message": err.Error()} } -func (server *Server) handleTzVersion(c echo.Context) error { +func (server *Server) handleTzVersionRequest(c echo.Context) error { return c.JSON(http.StatusOK, server.tzRelease) } diff --git a/web/server_test.go b/web/server_test.go index 2108a29..826b1ea 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -154,7 +154,7 @@ func Test_TzVersion(t *testing.T) { c.SetPath("/tz/version") // Assertions - if assert.NoError(t, server.handleTzVersion(c)) { + if assert.NoError(t, server.handleTzVersionRequest(c)) { assert.Equal(t, http.StatusOK, rec.Code) var version TzRelease reply := rec.Body.String() @@ -237,3 +237,57 @@ func Test_TzRequest(t *testing.T) { }) } } + +func Test_Auth(t *testing.T) { + settings := ConfigSchema{ + Tz: TzSchema{ + VersionFile: "../tzdata/version.json", + DatabaseName: "../tzdata/timezones.zip", + }, + Web: WebSchema{ + AuthTokenValue: "test", + AuthTokenParamName: "t", + }, + } + server, err := NewServer(settings) + assert.NoError(t, err) + + tests := []struct { + name string + reqPath string + wantCode int + }{ + { + "OK: version endpoint valid token", + "tz/version?t=test", + http.StatusOK, + }, + { + "OK: version endpoint valid token", + "tz/version?t=invalid", + http.StatusUnauthorized, + }, + } + + 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(tt.reqPath) + + fn := server.handleTzRequest(c) + if strings.HasPrefix(tt.reqPath, "tz/version") { + fn = server.handleTzVersionRequest(c) + } + if strings.HasPrefix(tt.reqPath, "time/") { + fn = server.handleTimeRequest(c) + } + + // Assertions + if assert.NoError(t, fn) { + assert.Equal(t, tt.wantCode, rec.Code) + } + }) + } +}