summaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
Diffstat (limited to 'pkg')
-rw-r--r--pkg/api/guess.go61
-rw-r--r--pkg/api/handler.go11
-rw-r--r--pkg/api/next.go28
-rw-r--r--pkg/api/state.go45
-rw-r--r--pkg/config/config.go22
-rw-r--r--pkg/db/config.go6
-rw-r--r--pkg/db/db.go24
-rw-r--r--pkg/middleware/context.go21
-rw-r--r--pkg/middleware/session/store.go232
-rw-r--r--pkg/middleware/state.go58
-rw-r--r--pkg/model/place.go84
-rw-r--r--pkg/service/places.go70
12 files changed, 662 insertions, 0 deletions
diff --git a/pkg/api/guess.go b/pkg/api/guess.go
new file mode 100644
index 0000000..25d08d6
--- /dev/null
+++ b/pkg/api/guess.go
@@ -0,0 +1,61 @@
+package api
+
+import (
+ "time"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/middleware"
+ "github.com/labstack/echo/v4"
+)
+
+func (h *Handler) PostGuess(c echo.Context) error {
+ req := &postGuessRequest{}
+ if err := c.Bind(req); err != nil {
+ return err
+ }
+
+ if state := c.Get("state"); state == nil {
+ return echo.ErrNotFound
+ }
+
+ state := c.Get("state").(*middleware.State)
+
+ resp, err := h.places.Guess(
+ c.Request().Context(),
+ state.CurrentGUID, req.Lat, req.Lng)
+ if err != nil {
+ return err
+ }
+
+ addPoints := 1000 - resp.Distance
+ if addPoints > 0 {
+ state.Points += addPoints
+ }
+ state.CurrentGUID = ""
+ state.Image = ""
+ if err := middleware.SetState(c, *state, int(365*24*time.Hour)); err != nil {
+ return err
+ }
+
+ return c.JSON(200, postGuessResponse{
+ GUID: resp.GUID,
+ Image: resp.Img,
+ Name: resp.Name,
+ Geojson: resp.Geojson,
+ Distance: resp.Distance,
+ State: state,
+ })
+}
+
+type postGuessRequest struct {
+ Lat float32 `json:"lat"`
+ Lng float32 `json:"lng"`
+}
+
+type postGuessResponse struct {
+ GUID string `json:"guid"`
+ Image string `json:"image"`
+ Name string `json:"name"`
+ Geojson any `json:"geojson"`
+ Distance int `json:"distance"`
+ State *middleware.State `json:"state"`
+}
diff --git a/pkg/api/handler.go b/pkg/api/handler.go
new file mode 100644
index 0000000..de1e8c3
--- /dev/null
+++ b/pkg/api/handler.go
@@ -0,0 +1,11 @@
+package api
+
+import "git.neonxp.ru/neonxp/guessr/pkg/service"
+
+type Handler struct {
+ places *service.Places
+}
+
+func New(places *service.Places) *Handler {
+ return &Handler{places: places}
+}
diff --git a/pkg/api/next.go b/pkg/api/next.go
new file mode 100644
index 0000000..c73af62
--- /dev/null
+++ b/pkg/api/next.go
@@ -0,0 +1,28 @@
+package api
+
+import (
+ "time"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/middleware"
+ "github.com/labstack/echo/v4"
+)
+
+func (h *Handler) PostNext(c echo.Context) error {
+ p, err := h.places.GetNext(c.Request().Context())
+ if err != nil {
+ return err
+ }
+
+ if state := c.Get("state"); state == nil {
+ return echo.ErrBadRequest
+ }
+
+ state := c.Get("state").(*middleware.State)
+ state.CurrentGUID = p.GUID
+ state.Image = p.Img
+ if err := middleware.SetState(c, *state, int(365*24*time.Hour)); err != nil {
+ return err
+ }
+
+ return c.JSON(200, state)
+}
diff --git a/pkg/api/state.go b/pkg/api/state.go
new file mode 100644
index 0000000..5ddd0b3
--- /dev/null
+++ b/pkg/api/state.go
@@ -0,0 +1,45 @@
+package api
+
+import (
+ "time"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/middleware"
+ "github.com/avito-tech/normalize"
+ "github.com/labstack/echo/v4"
+)
+
+func (h *Handler) GetState(c echo.Context) error {
+ if state := c.Get("state"); state == nil {
+ return c.JSON(200, &middleware.State{})
+ }
+
+ state := c.Get("state").(*middleware.State)
+
+ return c.JSON(200, state)
+}
+
+func (h *Handler) PostState(c echo.Context) error {
+ req := &postStateRequest{}
+ if err := c.Bind(req); err != nil {
+ return err
+ }
+
+ username := normalize.Normalize(req.Username)
+ if len(username) < 3 {
+ return echo.ErrBadRequest
+ }
+
+ state := &middleware.State{
+ Username: username,
+ }
+
+ if err := middleware.SetState(c, *state, int(365*24*time.Hour)); err != nil {
+ return err
+ }
+
+ return c.JSON(200, state)
+}
+
+type postStateRequest struct {
+ Username string `json:"username"`
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..31c2079
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,22 @@
+package config
+
+import (
+ "git.neonxp.ru/neonxp/guessr/pkg/db"
+ "github.com/caarlos0/env/v11"
+)
+
+type Config struct {
+ Listen string `env:"LISTEN"`
+ Debug bool `env:"DEBUG"`
+ Keys []string `env:"KEYS"`
+ DB *db.Config
+}
+
+func New() (*Config, error) {
+ cfg := &Config{
+ Listen: ":8000",
+ DB: &db.Config{},
+ }
+
+ return cfg, env.Parse(cfg)
+}
diff --git a/pkg/db/config.go b/pkg/db/config.go
new file mode 100644
index 0000000..0acab0c
--- /dev/null
+++ b/pkg/db/config.go
@@ -0,0 +1,6 @@
+package db
+
+type Config struct {
+ Database string `env:"DATABASE"`
+ Debug bool `env:"DB_DEBUG"`
+}
diff --git a/pkg/db/db.go b/pkg/db/db.go
new file mode 100644
index 0000000..7c361f6
--- /dev/null
+++ b/pkg/db/db.go
@@ -0,0 +1,24 @@
+package db
+
+import (
+ "database/sql"
+
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect/pgdialect"
+ "github.com/uptrace/bun/driver/pgdriver"
+ "github.com/uptrace/bun/extra/bundebug"
+)
+
+// dsn := "postgres://postgres:@localhost:5432/test?sslmode=disable"
+// dsn := "unix://user:pass@dbname/var/run/postgresql/.s.PGSQL.5432"
+
+func New(config *Config) *bun.DB {
+ sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(config.Database)))
+
+ db := bun.NewDB(sqldb, pgdialect.New())
+ if config.Debug {
+ db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
+ }
+
+ return db
+}
diff --git a/pkg/middleware/context.go b/pkg/middleware/context.go
new file mode 100644
index 0000000..f9c4425
--- /dev/null
+++ b/pkg/middleware/context.go
@@ -0,0 +1,21 @@
+package middleware
+
+import (
+ "context"
+
+ "github.com/labstack/echo/v4"
+)
+
+type ContextKey string
+
+func Context(key ContextKey, value any) echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ ctx := context.WithValue(c.Request().Context(), key, value)
+ r := c.Request().WithContext(ctx)
+ c.SetRequest(r)
+
+ return next(c)
+ }
+ }
+}
diff --git a/pkg/middleware/session/store.go b/pkg/middleware/session/store.go
new file mode 100644
index 0000000..78172d7
--- /dev/null
+++ b/pkg/middleware/session/store.go
@@ -0,0 +1,232 @@
+package session
+
+import (
+ "context"
+ "encoding/base32"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gorilla/securecookie"
+ "github.com/gorilla/sessions"
+ "github.com/uptrace/bun"
+)
+
+const (
+ sessionIDLen = 32
+ defaultTableName = "sessions"
+ defaultMaxAge = 60 * 60 * 24 * 30 // 30 days
+ defaultPath = "/"
+)
+
+// Options for bunstore.
+type Options struct {
+ TableName string
+ SkipCreateTable bool
+}
+
+// Store represent a bunstore.
+type Store struct {
+ db *bun.DB
+ opts Options
+ Codecs []securecookie.Codec
+ SessionOpts *sessions.Options
+}
+
+type Model struct {
+ bun.BaseModel `bun:"table:sessions,alias:s"`
+
+ ID string `bun:",pk,unique"`
+ Data string
+ CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ ExpiresAt time.Time
+}
+
+type KeyPairs []string
+
+func (k KeyPairs) ToKeys() [][]byte {
+ b := make([][]byte, 0, len(k))
+ for _, kk := range k {
+ b = append(b, []byte(kk))
+ }
+
+ return b
+}
+
+// New creates a new bunstore session.
+func New(db *bun.DB, keyPairs KeyPairs) (*Store, error) {
+ return NewOptions(db, Options{}, keyPairs)
+}
+
+// NewOptions creates a new bunstore session with options.
+func NewOptions(db *bun.DB, opts Options, keyPairs KeyPairs) (*Store, error) {
+ st := &Store{
+ db: db,
+ opts: opts,
+ Codecs: securecookie.CodecsFromPairs(keyPairs.ToKeys()...),
+ SessionOpts: &sessions.Options{
+ Path: defaultPath,
+ MaxAge: defaultMaxAge,
+ },
+ }
+
+ return st, nil
+}
+
+// Get returns a session for the given name after adding it to the registry.
+func (st *Store) Get(r *http.Request, name string) (*sessions.Session, error) {
+ return sessions.GetRegistry(r).Get(st, name)
+}
+
+// New creates a session with name without adding it to the registry.
+func (st *Store) New(r *http.Request, name string) (*sessions.Session, error) {
+ session := sessions.NewSession(st, name)
+ opts := *st.SessionOpts
+ session.Options = &opts
+ session.IsNew = true
+
+ st.MaxAge(st.SessionOpts.MaxAge)
+
+ // try fetch from db if there is a cookie
+ s := st.getSessionFromCookie(r, session.Name())
+ if s != nil {
+ if err := securecookie.DecodeMulti(session.Name(), s.Data, &session.Values, st.Codecs...); err != nil {
+ //nolint:nilerr
+ return session, nil
+ }
+
+ session.ID = s.ID
+ session.IsNew = false
+ }
+
+ return session, nil
+}
+
+// Save session and set cookie header.
+func (st *Store) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+ s := st.getSessionFromCookie(r, session.Name())
+
+ // delete if max age is < 0
+ if session.Options.MaxAge < 0 {
+ if s != nil {
+ if _, err := st.db.NewDelete().Model(&Model{ID: session.ID}).WherePK("id").Exec(r.Context()); err != nil {
+ return err
+ }
+ }
+
+ http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
+
+ return nil
+ }
+
+ data, err := securecookie.EncodeMulti(session.Name(), session.Values, st.Codecs...)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now()
+ expire := now.Add(time.Second * time.Duration(session.Options.MaxAge))
+
+ if s == nil {
+ // generate random session ID key suitable for storage in the db
+ session.ID = strings.TrimRight(
+ base32.StdEncoding.EncodeToString(
+ securecookie.GenerateRandomKey(sessionIDLen)), "=")
+ s = &Model{
+ ID: session.ID,
+ Data: data,
+ ExpiresAt: expire,
+ }
+
+ if _, err := st.db.NewInsert().Model(s).Exec(r.Context()); err != nil {
+ return err
+ }
+ } else {
+ s.Data = data
+ s.ExpiresAt = expire
+
+ if _, err := st.db.NewUpdate().Model(s).WherePK("id").Column("data", "expires_at").Exec(r.Context()); err != nil {
+ return err
+ }
+ }
+
+ // set session id cookie
+ id, err := securecookie.EncodeMulti(session.Name(), s.ID, st.Codecs...)
+ if err != nil {
+ return err
+ }
+
+ http.SetCookie(w, sessions.NewCookie(session.Name(), id, session.Options))
+
+ return nil
+}
+
+// getSessionFromCookie looks for an existing bunSession from a session ID stored inside a cookie.
+func (st *Store) getSessionFromCookie(r *http.Request, name string) *Model {
+ if cookie, err := r.Cookie(name); err == nil {
+ sessionID := ""
+ if err := securecookie.DecodeMulti(name, cookie.Value, &sessionID, st.Codecs...); err != nil {
+ return nil
+ }
+
+ s := &Model{}
+ if err := st.db.NewSelect().
+ Model(s).
+ Where("id = ? AND expires_at > ?", sessionID, time.Now()).
+ Scan(r.Context()); err != nil {
+ return nil
+ }
+
+ return s
+ }
+
+ return nil
+}
+
+// MaxAge sets the maximum age for the store and the underlying cookie
+// implementation. Individual sessions can be deleted by setting
+// Options.MaxAge = -1 for that session.
+func (st *Store) MaxAge(age int) {
+ st.SessionOpts.MaxAge = age
+ for _, codec := range st.Codecs {
+ if sc, ok := codec.(*securecookie.SecureCookie); ok {
+ sc.MaxAge(age)
+ }
+ }
+}
+
+// MaxLength restricts the maximum length of new sessions to l.
+// If l is 0 there is no limit to the size of a session, use with caution.
+// The default is 4096 (default for securecookie).
+func (st *Store) MaxLength(l int) {
+ for _, c := range st.Codecs {
+ if codec, ok := c.(*securecookie.SecureCookie); ok {
+ codec.MaxLength(l)
+ }
+ }
+}
+
+// Cleanup deletes expired sessions.
+func (st *Store) Cleanup() {
+ _, err := st.db.NewDelete().Model(&Model{}).Where("expires_at <= ?", time.Now()).Exec(context.Background())
+ if err != nil {
+ slog.Default().With("error", err).Error("cleanup")
+ }
+}
+
+// PeriodicCleanup runs Cleanup every interval. Close quit channel to stop.
+func (st *Store) PeriodicCleanup(interval time.Duration, quit <-chan struct{}) {
+ t := time.NewTicker(interval)
+ defer t.Stop()
+
+ for {
+ select {
+ case <-t.C:
+ st.Cleanup()
+ case <-quit:
+ return
+ }
+ }
+}
diff --git a/pkg/middleware/state.go b/pkg/middleware/state.go
new file mode 100644
index 0000000..c918411
--- /dev/null
+++ b/pkg/middleware/state.go
@@ -0,0 +1,58 @@
+package middleware
+
+import (
+ "context"
+ "encoding/gob"
+
+ "github.com/gorilla/sessions"
+ "github.com/labstack/echo-contrib/session"
+ "github.com/labstack/echo/v4"
+)
+
+func init() {
+ gob.Register(&State{})
+}
+
+func PopulateState() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ sess, err := session.Get("state", c)
+ if err != nil {
+ return err
+ }
+
+ u := sess.Values["state"]
+ c.Set("state", u)
+
+ ctx := context.WithValue(c.Request().Context(), ContextKey("user"), u)
+ r := c.Request().WithContext(ctx)
+ c.SetRequest(r)
+
+ return next(c)
+ }
+ }
+}
+
+func SetState(c echo.Context, u State, maxage int) error {
+ sess, err := session.Get("state", c)
+ if err != nil {
+ return err
+ }
+
+ sess.Values["state"] = u
+ sess.Options = &sessions.Options{
+ Path: "/",
+ MaxAge: maxage,
+ HttpOnly: true,
+ Secure: true,
+ }
+
+ return sess.Save(c.Request(), c.Response())
+}
+
+type State struct {
+ Username string `json:"username"`
+ CurrentGUID string `json:"current_guid"`
+ Points int `json:"points"`
+ Image string `json:"image"`
+}
diff --git a/pkg/model/place.go b/pkg/model/place.go
new file mode 100644
index 0000000..54ce038
--- /dev/null
+++ b/pkg/model/place.go
@@ -0,0 +1,84 @@
+package model
+
+import (
+ "database/sql/driver"
+ "fmt"
+ "math"
+ "regexp"
+ "strconv"
+ "time"
+
+ "github.com/uptrace/bun"
+)
+
+type Place struct {
+ bun.BaseModel `bun:"table:places,alias:p"`
+
+ GUID string `bun:",pk,unique"`
+ Position *Point `bun:"type:geometry(Point, 4326)"`
+ Img string
+ Name string
+ Count int
+ CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
+ DeletedAt time.Time `bun:",soft_delete,nullzero"`
+}
+
+type Point struct {
+ Lat float64
+ Lon float64
+}
+
+func (p *Point) Value() (driver.Value, error) {
+ return fmt.Sprintf(`SRID=4326;POINT(%f %f)`, p.Lon, p.Lat), nil
+}
+
+func (p *Point) Scan(src any) (err error) {
+ re := regexp.MustCompile(`/\((.+?) (.+?)\)/`)
+ s := ""
+ //nolint:revive
+ switch src := src.(type) {
+ case string:
+ s = src
+ case []uint8:
+ s = string(src)
+ default:
+ return fmt.Errorf("unsupported data type: %T", src)
+ }
+ //nolint:gomnd
+ subs := re.FindAllString(s, 2)
+
+ lon, err := strconv.ParseFloat(subs[0], 64)
+ if err != nil {
+ return err
+ }
+
+ lat, err := strconv.ParseFloat(subs[1], 64)
+ if err != nil {
+ return err
+ }
+
+ p.Lat = lat
+ p.Lon = lon
+
+ return nil
+}
+
+const radius = 6371 // Earth's mean radius in kilometers
+
+func degrees2radians(degrees float64) float64 {
+ return degrees * math.Pi / 180
+}
+
+func (p *Point) Distance(destination *Point) float64 {
+ degreesLat := degrees2radians(destination.Lat - p.Lat)
+ degreesLong := degrees2radians(destination.Lon - p.Lon)
+ a := (math.Sin(degreesLat/2)*math.Sin(degreesLat/2) +
+ math.Cos(degrees2radians(p.Lat))*
+ math.Cos(degrees2radians(destination.Lat))*math.Sin(degreesLong/2)*
+ math.Sin(degreesLong/2))
+ c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
+ d := radius * c
+
+ return d
+}
diff --git a/pkg/service/places.go b/pkg/service/places.go
new file mode 100644
index 0000000..d088836
--- /dev/null
+++ b/pkg/service/places.go
@@ -0,0 +1,70 @@
+package service
+
+import (
+ "context"
+ "database/sql"
+
+ "git.neonxp.ru/neonxp/guessr/pkg/model"
+ "github.com/uptrace/bun"
+)
+
+type Places struct {
+ db *bun.DB
+}
+
+func New(db *bun.DB) *Places {
+ return &Places{db: db}
+}
+
+func (p *Places) GetNext(ctx context.Context) (*model.Place, error) {
+ r := new(model.Place)
+ btx, err := p.db.BeginTx(ctx, &sql.TxOptions{})
+ if err != nil {
+ return nil, err
+ }
+
+ err = btx.NewSelect().
+ ColumnExpr(`p.guid, p.img`).
+ Model(r).
+ Where(`p.count = (SELECT MIN(pl.count) FROM places pl WHERE pl.deleted_at IS NULL)`).
+ OrderExpr(`RANDOM()`).
+ Limit(1).
+ Scan(ctx, r)
+ if err != nil {
+ return nil, err
+ }
+ _, err = btx.NewUpdate().
+ Model(r).
+ Set(`count = count + 1`).
+ WherePK("guid").
+ Exec(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ return r, btx.Commit()
+}
+
+func (p *Places) Guess(ctx context.Context, guid string, lat, lon float32) (*GuessResult, error) {
+ r := &GuessResult{}
+ err := p.db.NewSelect().
+ Model(&model.Place{GUID: guid}).
+ WherePK("guid").
+ ColumnExpr(`p.name, p.guid, p.img,
+ ST_Distance(ST_MakePoint(?, ?)::geography, p.position::geography)::int AS distance,
+ ST_AsGeoJSON(ST_MakeLine(
+ ST_SetSRID(ST_MakePoint(?, ?), 4326),
+ ST_SetSRID(p.position, 4326)
+ )) AS geojson`, lon, lat, lon, lat).
+ Scan(ctx, r)
+
+ return r, err
+}
+
+type GuessResult struct {
+ GUID string `json:"guid"`
+ Img string `json:"img"`
+ Name string `json:"name"`
+ Geojson any `json:"geojson"`
+ Distance int `json:"distance"`
+}