Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 24 additions & 2 deletions db/db.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
37 changes: 37 additions & 0 deletions db/rtree.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"strings"
"time"

"archive/zip"

Expand Down Expand Up @@ -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
Expand Down
38 changes: 25 additions & 13 deletions web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}

Expand Down
56 changes: 55 additions & 1 deletion web/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
})
}
}