From 34ccc98a942098faefb5f4211b215ff9ccc7ad0e Mon Sep 17 00:00:00 2001 From: Alexander Neonxp Kiryukhin Date: Mon, 9 Dec 2024 01:07:15 +0300 Subject: Начальный MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/api/guess.go | 61 +++++++++++ pkg/api/handler.go | 11 ++ pkg/api/next.go | 28 +++++ pkg/api/state.go | 45 ++++++++ pkg/config/config.go | 22 ++++ pkg/db/config.go | 6 ++ pkg/db/db.go | 24 +++++ pkg/middleware/context.go | 21 ++++ pkg/middleware/session/store.go | 232 ++++++++++++++++++++++++++++++++++++++++ pkg/middleware/state.go | 58 ++++++++++ pkg/model/place.go | 84 +++++++++++++++ pkg/service/places.go | 70 ++++++++++++ 12 files changed, 662 insertions(+) create mode 100644 pkg/api/guess.go create mode 100644 pkg/api/handler.go create mode 100644 pkg/api/next.go create mode 100644 pkg/api/state.go create mode 100644 pkg/config/config.go create mode 100644 pkg/db/config.go create mode 100644 pkg/db/db.go create mode 100644 pkg/middleware/context.go create mode 100644 pkg/middleware/session/store.go create mode 100644 pkg/middleware/state.go create mode 100644 pkg/model/place.go create mode 100644 pkg/service/places.go (limited to 'pkg') 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"` +} -- cgit v1.2.3