git » repo » main » tree

[main] / services / places / main.go

package main

import (
	"encoding/json"
	"errors"
	"github.com/ahmetb/go-linq/v3"
	"github.com/glebarez/sqlite"
	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
	echojwt "github.com/labstack/echo-jwt/v4"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
	"net/http"
	"os"
	"path/filepath"
	"time"
)

const (
	DbFile = "data/places.db?_pragma=journal_mode(WAL)&_pragma=_synchronous(NORMAL)"

	AuthCookieName = "auth"
	TokenCtxName   = "token"
	UserIdCtxName  = "userId"

	ListTake       = 30
	MaxRoutePlaces = 10
	MaxFieldLength = 64

	ErrorInvalidPlace    = "invalid place"
	ErrorInvalidRoute    = "invalid route"
	ErrorUnauthorized    = "token not found or invalid"
	ErrorUpdateForbidden = "you can only change your own places"
)

var (
	EncryptionKey = LoadOrCreateKey("settings/enc.key", 16)
	JwtSecret     = LoadOrCreateKey("settings/jwt.key", 16)
	db            *gorm.DB
)

func main() {
	var err error

	err = os.Mkdir(filepath.Dir("data/"), 0750)
	if err != nil && !os.IsExist(err) {
		panic(err)
	}

	db, err = gorm.Open(sqlite.Open(DbFile), &gorm.Config{})
	if err != nil {
		panic(err)
	}

	err = db.AutoMigrate(&PlaceData{})
	if err != nil {
		panic(err)
	}

	e := echo.New()
	e.Use(middleware.BodyLimit("1k"))
	e.Use(middleware.StaticWithConfig(middleware.StaticConfig{Root: "wwwroot"}))
	e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{Timeout: 10 * time.Second}))

	r := e.Group("/api")
	{
		r.Use(echojwt.WithConfig(echojwt.Config{
			SigningKey:    JwtSecret,
			SigningMethod: jwt.SigningMethodHS256.Name,
			TokenLookup:   "cookie:" + AuthCookieName,
			ContextKey:    TokenCtxName,
			ErrorHandler: func(c echo.Context, err error) error {
				return nil
			},
			SuccessHandler: func(c echo.Context) {
				if subject, err := c.Get(TokenCtxName).(*jwt.Token).Claims.(jwt.MapClaims).GetSubject(); err == nil {
					if userId, err := uuid.Parse(subject); err == nil {
						c.Set(UserIdCtxName, userId)
						return
					}
				}
			},
			ContinueOnIgnoredError: true,
		}))

		r.GET("/auth", auth)
		r.GET("/list", list)
		r.PUT("/put/place", put)
		r.PUT("/put/place/:id", put)
		r.GET("/get/place/:id", get)
		r.POST("/route", route)
	}

	if err := e.Start(":8080"); !errors.Is(err, http.ErrServerClosed) {
		panic(err)
	}
}

func auth(c echo.Context) error {
	userId, ok := c.Get(UserIdCtxName).(uuid.UUID)
	if !ok {
		userId = uuid.New()
		signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{Subject: userId.String()}).SignedString(JwtSecret)
		if err != nil {
			return err
		}

		cookie := new(http.Cookie)
		cookie.Name = AuthCookieName
		cookie.Value = signed
		cookie.HttpOnly = true
		cookie.SameSite = http.SameSiteStrictMode
		c.SetCookie(cookie)
	}

	var coords Coords
	if err := c.Bind(&coords); err != nil {
		coords = Coords{}
	}

	placeId := PlaceId{UserId: userId, Lat: coords.Lat, Long: coords.Long}
	id, err := placeId.ToString(EncryptionKey)
	if err != nil {
		return err
	}

	return c.String(http.StatusOK, id)
}

func list(c echo.Context) error {
	var places [ListTake]string
	if result := db.Model(&PlaceData{}).Select("Id").Limit(ListTake).Order("Updated desc").Find(&places); result.Error != nil {
		return result.Error
	}

	c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	c.Response().WriteHeader(http.StatusOK)
	enc := json.NewEncoder(c.Response())

	linq.From(places).WhereT(func(place string) bool {
		return place != ""
	}).SelectT(func(id string) CoordsWithId {
		if placeId, err := PlaceIdFromString(id, EncryptionKey); err == nil {
			return CoordsWithId{Id: id, Lat: placeId.Lat, Long: placeId.Long}
		}
		return CoordsWithId{}
	}).WhereT(func(coords CoordsWithId) bool {
		return coords.Id != ""
	}).ForEachT(func(coords CoordsWithId) {
		enc.Encode(coords)
	})

	return nil
}

func get(c echo.Context) error {
	userId, ok := c.Get(UserIdCtxName).(uuid.UUID)
	if !ok {
		return c.String(http.StatusUnauthorized, ErrorUnauthorized)
	}

	id := c.Param("id")

	placeId, err := PlaceIdFromString(id, EncryptionKey)
	if err != nil {
		return c.String(http.StatusBadRequest, ErrorInvalidPlace)
	}

	data := PlaceData{Id: id}
	if result := db.Find(&data); result.Error != nil {
		return result.Error
	}

	place := toPlaceInfo(userId, placeId, data)

	return c.JSON(http.StatusOK, place)
}

func put(c echo.Context) error {
	userId, ok := c.Get(UserIdCtxName).(uuid.UUID)
	if !ok {
		return c.String(http.StatusUnauthorized, ErrorUnauthorized)
	}

	var place PlaceInfo
	if err := c.Bind(&place); err != nil || place.Lat < -90 || place.Lat > 90 || place.Long < -180 || place.Long > 180 || len(place.Public) > MaxFieldLength || len(place.Secret) > MaxFieldLength {
		return c.String(http.StatusBadRequest, ErrorInvalidPlace)
	}

	id := c.Param("id")
	if id != "" {
		placeId, err := PlaceIdFromString(id, EncryptionKey)
		if err != nil {
			return c.String(http.StatusBadRequest, ErrorInvalidPlace)
		}

		if placeId.UserId != userId {
			return c.String(http.StatusForbidden, ErrorUpdateForbidden)
		}

		place.Long = placeId.Long
		place.Lat = placeId.Lat
	}

	id, err := PlaceId{UserId: userId, Long: place.Long, Lat: place.Lat}.ToString(EncryptionKey)
	if err != nil {
		return err
	}

	result := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&PlaceData{
		Id:      id,
		Public:  place.Public,
		Secret:  place.Secret,
		Updated: time.Now(),
	})

	if result.Error != nil {
		return result.Error
	}

	return c.String(http.StatusOK, id)
}

func route(c echo.Context) error {
	userId, ok := c.Get(UserIdCtxName).(uuid.UUID)
	if !ok {
		return c.String(http.StatusUnauthorized, ErrorUnauthorized)
	}

	var places []string
	if err := (&echo.DefaultBinder{}).BindBody(c, &places); err != nil || len(places) > MaxRoutePlaces {
		return c.String(http.StatusBadRequest, ErrorInvalidRoute)
	}

	var err error

	var route []PlaceId
	linq.From(places).
		OrderByT(func(item string) string { return item }).
		SelectT(func(item string) PlaceId {
			placeId, e := PlaceIdFromString(item, EncryptionKey)
			if e != nil {
				err = e
			}
			return placeId
		}).
		Distinct().
		ToSlice(&route)
	if err != nil {
		return c.String(http.StatusBadRequest, ErrorInvalidRoute)
	}

	var saved []PlaceData
	result := db.Order("Id asc").Find(&saved, places)
	if result.Error != nil {
		return result.Error
	}

	if result.RowsAffected != int64(linq.From(places).Distinct().Count()) || len(saved) != len(route) {
		return c.String(http.StatusBadRequest, ErrorInvalidRoute)
	}

	c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	c.Response().WriteHeader(http.StatusOK)
	enc := json.NewEncoder(c.Response())

	linq.From(route).ZipT(linq.From(saved), func(placeId PlaceId, data PlaceData) PlaceInfo {
		return toPlaceInfo(userId, placeId, data)
	}).ForEachT(func(item PlaceInfo) {
		enc.Encode(item)
	})

	return nil
}

func toPlaceInfo(userId uuid.UUID, placeId PlaceId, data PlaceData) PlaceInfo {
	var secret string
	if placeId.UserId == userId {
		secret = data.Secret
	} else {
		secret = ""
	}

	return PlaceInfo{
		Lat:    placeId.Lat,
		Long:   placeId.Long,
		Public: data.Public,
		Secret: secret,
	}
}

type PlaceData struct {
	Id      string `gorm:"primarykey;size:64"`
	Public  string
	Secret  string
	Updated time.Time `gorm:"index:,sort:desc"`
}

type Coords struct {
	Lat  float64 `query:"lat"`
	Long float64 `query:"long"`
}

type CoordsWithId struct {
	Id   string  `json:"id"`
	Lat  float64 `json:"lat"`
	Long float64 `json:"long"`
}

type PlaceInfo struct {
	Lat    float64 `json:"lat"`
	Long   float64 `json:"long"`
	Public string  `json:"public"`
	Secret string  `json:"secret"`
}