Compare commits

..

2 Commits

Author SHA1 Message Date
zegkljan
7c8a430f49 App version management.
* DB migration adds app_versions table for managing known app versions.
* /ping reads app version from User-Agent header and responds with latest version if the app is OKO and its version is older than the latest one stored.
* Added endpoint /app-versions which
  * lists all known versions via GET
  * adds/updates a version via POST

#3
Fix #4
2022-09-23 00:59:20 +02:00
zegkljan
c9377b04fc DB migration, small technicalities.
* DB migration rewritten:
  * Migration scripts are embedded as FS.
  * Migration versions are handled automatically.
* Use generics in utils.
2022-09-22 22:05:41 +02:00
10 changed files with 288 additions and 74 deletions

6
go.mod
View File

@ -4,14 +4,15 @@ go 1.19
require ( require (
crawshaw.io/sqlite v0.3.2 crawshaw.io/sqlite v0.3.2
github.com/consbio/mbtileserver v0.8.1 github.com/consbio/mbtileserver v0.8.2
github.com/gin-gonic/gin v1.7.7 github.com/gin-gonic/gin v1.7.7
github.com/paulmach/go.geojson v1.4.0 github.com/paulmach/go.geojson v1.4.0
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1
) )
require ( require (
github.com/brendan-ward/mbtiles-go v0.0.0-20211210015813-553bc514bbdf // indirect github.com/brendan-ward/mbtiles-go v0.1.1-0.20220129145719-67a7dabdbaab // indirect
github.com/coreos/go-semver v0.3.0
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect
@ -22,6 +23,7 @@ require (
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mssola/user_agent v0.5.3
github.com/ugorji/go/codec v1.2.6 // indirect github.com/ugorji/go/codec v1.2.6 // indirect
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 golang.org/x/image v0.0.0-20220722155232-062f8c9fd539

11
go.sum
View File

@ -68,8 +68,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/brendan-ward/mbtiles-go v0.0.0-20211210015813-553bc514bbdf h1:RT/qOIiM0yuaELiwbyli7g6bHFARWlU8B27Ff1r8K6g= github.com/brendan-ward/mbtiles-go v0.1.1-0.20220129145719-67a7dabdbaab h1:KC2TqbUmfkIrTwVpkH1ysJiZicPoSyb6chH3JxKHqBM=
github.com/brendan-ward/mbtiles-go v0.0.0-20211210015813-553bc514bbdf/go.mod h1:rlKkU0/sjOysMB+dvVI+b90UyzmD5alQK7KlhJplVrg= github.com/brendan-ward/mbtiles-go v0.1.1-0.20220129145719-67a7dabdbaab/go.mod h1:rlKkU0/sjOysMB+dvVI+b90UyzmD5alQK7KlhJplVrg=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
@ -92,8 +92,9 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/consbio/mbtileserver v0.8.1 h1:Q1x3Vf4Wbb92uNQ9ZAIn2bkS+nzMbcnH2qidq81lxOs= github.com/consbio/mbtileserver v0.8.2 h1:iXOBkCp4r/7Z7wC4sw6dYkFAJnuyVpQMu0dbT+RHSxE=
github.com/consbio/mbtileserver v0.8.1/go.mod h1:efp5wvQOhIhElvcXrFhXUw+k43dwn9EfdACvXBwHy7o= github.com/consbio/mbtileserver v0.8.2/go.mod h1:lVBCXeAL6OzooRXWbQ/MZFGFtwm/0A1fkeNTf9T4gzA=
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@ -317,6 +318,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mssola/user_agent v0.5.3 h1:lBRPML9mdFuIZgI2cmlQ+atbpJdLdeVl2IDodjBR578=
github.com/mssola/user_agent v0.5.3/go.mod h1:TTPno8LPY3wAIEKRpAtkdMT0f8SE24pLRGPahjCH4uw=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=

View File

@ -4,6 +4,7 @@ import (
"io" "io"
"time" "time"
"github.com/coreos/go-semver/semver"
geojson "github.com/paulmach/go.geojson" geojson "github.com/paulmach/go.geojson"
) )
@ -40,6 +41,11 @@ type BuildInfo struct {
// transport objects // transport objects
type AppVersionInfo struct {
Version semver.Version `json:"version"`
Address string `json:"address"`
}
type Coords struct { type Coords struct {
Lat float64 `json:"lat"` Lat float64 `json:"lat"`
Lng float64 `json:"lng"` Lng float64 `json:"lng"`

View File

@ -5,6 +5,7 @@ const (
URIBuildInfo = "/build-info" URIBuildInfo = "/build-info"
URIHardFail = "/hard-fail" URIHardFail = "/hard-fail"
URISoftFail = "/soft-fail" URISoftFail = "/soft-fail"
URIAppVersions = "/app-versions"
URIReinit = "/reinit" URIReinit = "/reinit"
URIMapPack = "/mappack" URIMapPack = "/mappack"
URIHandshake = "/handshake" URIHandshake = "/handshake"
@ -16,4 +17,6 @@ const (
URITileserverRoot = "/tileserver" URITileserverRoot = "/tileserver"
URITileserver = URITileserverRoot + "/*x" URITileserver = URITileserverRoot + "/*x"
URITileTemplate = URITileserverRoot + "/map/tiles/{z}/{x}/{y}.pbf" URITileTemplate = URITileserverRoot + "/map/tiles/{z}/{x}/{y}.pbf"
AppName = "OKO"
) )

View File

@ -13,14 +13,22 @@ import (
"cernobor.cz/oko-server/errs" "cernobor.cz/oko-server/errs"
"cernobor.cz/oko-server/models" "cernobor.cz/oko-server/models"
"github.com/coreos/go-semver/semver"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mssola/user_agent"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
func internalError(gc *gin.Context, err error) { func internalError(gc *gin.Context, err error) {
gc.Error(err)
gc.String(http.StatusInternalServerError, "%v", err) gc.String(http.StatusInternalServerError, "%v", err)
} }
func badRequest(gc *gin.Context, err error) {
gc.Error(err)
gc.String(http.StatusBadRequest, "%v", err)
}
func (s *Server) setupRouter() *gin.Engine { func (s *Server) setupRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
router := gin.New() router := gin.New()
@ -77,10 +85,23 @@ func (s *Server) setupRouter() *gin.Engine {
} }
}) })
// tileserver
router.GET(URITileserver, gin.WrapH(s.tileserverSvSet.Handler()))
/*** API ***/
router.GET(URIPing, s.handleGETPing)
router.POST(URIHandshake, s.handlePOSTHandshake)
router.GET(URIData, s.handleGETData)
router.POST(URIData, s.handlePOSTData)
router.GET(URIDataPeople, s.handleGETDataPeople)
router.GET(URIDataFeatures, s.handleGETDataFeatures)
router.GET(URIDataFeaturesPhoto, s.handleGETDataFeaturesPhoto)
router.GET(URIDataProposals, s.handleGETDataProposals)
// resources
router.GET(URIMapPack, s.handleGETTilepack)
// utility/debug paths // utility/debug paths
router.GET(URIPing, func(gc *gin.Context) {
gc.Status(http.StatusNoContent)
})
router.GET(URIBuildInfo, func(gc *gin.Context) { router.GET(URIBuildInfo, func(gc *gin.Context) {
gc.JSON(http.StatusOK, models.BuildInfo{ gc.JSON(http.StatusOK, models.BuildInfo{
VersionHash: s.config.VersionHash, VersionHash: s.config.VersionHash,
@ -93,26 +114,72 @@ func (s *Server) setupRouter() *gin.Engine {
router.GET(URISoftFail, func(gc *gin.Context) { router.GET(URISoftFail, func(gc *gin.Context) {
gc.JSON(http.StatusOK, map[string]string{"error": "artificial fail"}) gc.JSON(http.StatusOK, map[string]string{"error": "artificial fail"})
}) })
router.GET(URIAppVersions, s.handleGETAppVersions)
router.POST(URIAppVersions, s.handlePOSTAppVersions)
router.POST(URIReinit, s.handlePOSTReset) router.POST(URIReinit, s.handlePOSTReset)
// resources
router.GET(URIMapPack, s.handleGETTilepack)
// API
router.POST(URIHandshake, s.handlePOSTHandshake)
router.GET(URIData, s.handleGETData)
router.POST(URIData, s.handlePOSTData)
router.GET(URIDataPeople, s.handleGETDataPeople)
router.GET(URIDataFeatures, s.handleGETDataFeatures)
router.GET(URIDataFeaturesPhoto, s.handleGETDataFeaturesPhoto)
router.GET(URIDataProposals, s.handleGETDataProposals)
// tileserver
router.GET(URITileserver, gin.WrapH(s.tileserverSvSet.Handler()))
return router return router
} }
func extractAppVersion(gc *gin.Context) (*semver.Version, error) {
ua := user_agent.New(gc.Request.UserAgent())
n, v := ua.Browser()
if n != AppName {
return nil, nil
}
version, err := semver.NewVersion(v)
if err != nil {
return nil, fmt.Errorf("malformed version in User-Agent header: %w", err)
}
return version, nil
}
func (s *Server) handleGETPing(gc *gin.Context) {
version, err := extractAppVersion(gc)
if err != nil {
badRequest(gc, err)
return
}
res, err := s.getLatestVersion(version)
if err != nil {
internalError(gc, err)
return
}
if res == nil {
gc.Status(http.StatusNoContent)
} else {
gc.JSON(http.StatusOK, res)
}
}
func (s *Server) handleGETAppVersions(gc *gin.Context) {
versions, err := s.getAppVersions()
if err != nil {
internalError(gc, err)
return
}
gc.JSON(http.StatusOK, versions)
}
func (s *Server) handlePOSTAppVersions(gc *gin.Context) {
var versionInfo models.AppVersionInfo
err := gc.ShouldBindJSON(&versionInfo)
if err != nil {
badRequest(gc, fmt.Errorf("malformed version info: %w", err))
return
}
err = s.putAppVersion(&versionInfo)
if err != nil {
internalError(gc, err)
return
}
gc.Status(http.StatusNoContent)
}
func (s *Server) handlePOSTReset(gc *gin.Context) { func (s *Server) handlePOSTReset(gc *gin.Context) {
err := s.initDB(true) err := s.initDB(true)
if err != nil { if err != nil {
@ -130,7 +197,7 @@ func (s *Server) handlePOSTHandshake(gc *gin.Context) {
var hs models.HandshakeChallenge var hs models.HandshakeChallenge
err := gc.ShouldBindJSON(&hs) err := gc.ShouldBindJSON(&hs)
if err != nil { if err != nil {
gc.String(http.StatusBadRequest, fmt.Sprintf("malformed handshake challenge: %v", err)) badRequest(gc, fmt.Errorf("malformed handshake challenge: %w", err))
return return
} }
@ -205,7 +272,7 @@ func (s *Server) handlePOSTData(gc *gin.Context) {
case "multipart/form-data": case "multipart/form-data":
s.handlePOSTDataMultipart(gc) s.handlePOSTDataMultipart(gc)
default: default:
gc.String(http.StatusBadRequest, "unsupported Content-Type") badRequest(gc, fmt.Errorf("unsupported Content-Type"))
} }
} }
@ -213,17 +280,17 @@ func (s *Server) handlePOSTDataJSON(gc *gin.Context) {
var data models.Update var data models.Update
err := gc.ShouldBindJSON(&data) err := gc.ShouldBindJSON(&data)
if err != nil { if err != nil {
gc.String(http.StatusBadRequest, fmt.Sprintf("malformed data: %v", err)) badRequest(gc, fmt.Errorf("malformed data: %w", err))
return return
} }
if !isUniqueFeatureID(data.Create) { if !isUniqueFeatureID(data.Create) {
gc.String(http.StatusBadRequest, "created features do not have unique IDs") badRequest(gc, fmt.Errorf("created features do not have unique IDs"))
return return
} }
if data.CreatedPhotos != nil || data.AddPhotos != nil { if data.CreatedPhotos != nil || data.AddPhotos != nil {
gc.String(http.StatusBadRequest, "created_photos and/or add_photos present, but Content-Type is application/json") badRequest(gc, fmt.Errorf("created_photos and/or add_photos present, but Content-Type is application/json"))
return return
} }
@ -238,7 +305,7 @@ func (s *Server) handlePOSTDataJSON(gc *gin.Context) {
func (s *Server) handlePOSTDataMultipart(gc *gin.Context) { func (s *Server) handlePOSTDataMultipart(gc *gin.Context) {
form, err := gc.MultipartForm() form, err := gc.MultipartForm()
if err != nil { if err != nil {
gc.String(http.StatusBadRequest, "malformed multipart/form-data content") badRequest(gc, fmt.Errorf("malformed multipart/form-data content"))
return return
} }
@ -246,11 +313,11 @@ func (s *Server) handlePOSTDataMultipart(gc *gin.Context) {
if !ok { if !ok {
dataFile, ok := form.File["data"] dataFile, ok := form.File["data"]
if !ok { if !ok {
gc.String(http.StatusBadRequest, "value 'data' is missing from the content") badRequest(gc, fmt.Errorf("value 'data' is missing from the content"))
return return
} }
if len(dataFile) != 1 { if len(dataFile) != 1 {
gc.String(http.StatusBadRequest, "value 'data' does not contain exactly 1 item") badRequest(gc, fmt.Errorf("value 'data' does not contain exactly 1 item"))
return return
} }
df, err := dataFile[0].Open() df, err := dataFile[0].Open()
@ -266,26 +333,26 @@ func (s *Server) handlePOSTDataMultipart(gc *gin.Context) {
dataStr = []string{string(dataBytes)} dataStr = []string{string(dataBytes)}
} }
if len(dataStr) != 1 { if len(dataStr) != 1 {
gc.String(http.StatusBadRequest, "value 'data' does not contain exactly 1 item") badRequest(gc, fmt.Errorf("value 'data' does not contain exactly 1 item"))
return return
} }
var data models.Update var data models.Update
err = json.Unmarshal([]byte(dataStr[0]), &data) err = json.Unmarshal([]byte(dataStr[0]), &data)
if err != nil { if err != nil {
gc.String(http.StatusBadRequest, "malformed 'data' value: %v", err) badRequest(gc, fmt.Errorf("malformed 'data' value: %w", err))
return return
} }
if !isUniqueFeatureID(data.Create) { if !isUniqueFeatureID(data.Create) {
gc.String(http.StatusBadRequest, "created features do not have unique IDs") badRequest(gc, fmt.Errorf("created features do not have unique IDs"))
return return
} }
photos := make(map[string]models.Photo, len(form.File)) photos := make(map[string]models.Photo, len(form.File))
for name, fh := range form.File { for name, fh := range form.File {
if len(fh) != 1 { if len(fh) != 1 {
gc.String(http.StatusBadRequest, "file item %s does not contain exactly 1 file", name) badRequest(gc, fmt.Errorf("file item %s does not contain exactly 1 file", name))
return return
} }
var photo models.Photo var photo models.Photo
@ -303,7 +370,7 @@ func (s *Server) handlePOSTDataMultipart(gc *gin.Context) {
if err != nil { if err != nil {
var e *errs.ErrUnsupportedContentType var e *errs.ErrUnsupportedContentType
if errors.As(err, &e) { if errors.As(err, &e) {
gc.String(http.StatusBadRequest, e.Error()) badRequest(gc, e)
return return
} }
internalError(gc, fmt.Errorf("failed to update data: %w", err)) internalError(gc, fmt.Errorf("failed to update data: %w", err))
@ -334,12 +401,12 @@ func (s *Server) handleGETDataFeatures(gc *gin.Context) {
func (s *Server) handleGETDataFeaturesPhoto(gc *gin.Context) { func (s *Server) handleGETDataFeaturesPhoto(gc *gin.Context) {
reqFeatureID, err := strconv.Atoi(gc.Param("feature")) reqFeatureID, err := strconv.Atoi(gc.Param("feature"))
if err != nil { if err != nil {
gc.String(http.StatusBadRequest, "malformed feature ID") badRequest(gc, fmt.Errorf("malformed feature ID"))
return return
} }
reqPhotoID, err := strconv.Atoi(gc.Param("photo")) reqPhotoID, err := strconv.Atoi(gc.Param("photo"))
if err != nil { if err != nil {
gc.String(http.StatusBadRequest, "malformed photo ID") badRequest(gc, fmt.Errorf("malformed photo ID"))
return return
} }

View File

@ -4,30 +4,31 @@ import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context" "context"
"embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path"
"regexp"
"sort"
"strconv"
"time" "time"
_ "embed"
"cernobor.cz/oko-server/errs" "cernobor.cz/oko-server/errs"
"cernobor.cz/oko-server/models" "cernobor.cz/oko-server/models"
"crawshaw.io/sqlite" "crawshaw.io/sqlite"
"crawshaw.io/sqlite/sqlitex" "crawshaw.io/sqlite/sqlitex"
mbsh "github.com/consbio/mbtileserver/handlers" mbsh "github.com/consbio/mbtileserver/handlers"
"github.com/coreos/go-semver/semver"
geojson "github.com/paulmach/go.geojson" geojson "github.com/paulmach/go.geojson"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
//go:embed sql_schema/V1_init.sql //go:embed sql_schema/V*.sql
var sql_v1 string var sqlSchema embed.FS
//go:embed sql_schema/V2_proposals.sql
var sql_v2 string
type Server struct { type Server struct {
config ServerConfig config ServerConfig
@ -240,7 +241,7 @@ func (s *Server) initDB(reinit bool) error {
defer s.requestCheckpoint() defer s.requestCheckpoint()
if reinit { if reinit {
s.log.Warn("REinitializing DB.") s.log.Warn("Reinitializing DB.")
tables := []string{} tables := []string{}
err := sqlitex.Exec(conn, "select name from sqlite_master where type = 'table'", func(stmt *sqlite.Stmt) error { err := sqlitex.Exec(conn, "select name from sqlite_master where type = 'table'", func(stmt *sqlite.Stmt) error {
tables = append(tables, stmt.ColumnText(0)) tables = append(tables, stmt.ColumnText(0))
@ -262,6 +263,16 @@ func (s *Server) initDB(reinit bool) error {
return fmt.Errorf("failed to reset user version: %w", err) return fmt.Errorf("failed to reset user version: %w", err)
} }
} }
err := s.migrateDb(conn)
if err != nil {
return fmt.Errorf("failed to migrate db: %w", err)
}
return nil
}
func (s *Server) migrateDb(conn *sqlite.Conn) error {
var version int var version int
err := sqlitex.Exec(conn, "PRAGMA user_version", func(stmt *sqlite.Stmt) error { err := sqlitex.Exec(conn, "PRAGMA user_version", func(stmt *sqlite.Stmt) error {
version = stmt.ColumnInt(0) version = stmt.ColumnInt(0)
@ -270,23 +281,153 @@ func (s *Server) initDB(reinit bool) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to get user version: %w", err) return fmt.Errorf("failed to get user version: %w", err)
} }
s.log.Debugf("Current db version: %d", version) s.log.Debugf("Current db version: %d", version)
if version <= 0 {
s.log.Debugf("Running db migration V1") entries, err := sqlSchema.ReadDir("sql_schema")
err = sqlitex.ExecScript(conn, sql_v1) if err != nil {
return fmt.Errorf("failed to read sql_schema migrations: %w", err)
}
type migration struct {
file string
version int
name string
}
pattern := regexp.MustCompile("^V([0-9]+)_(.*)[.][sS][qQ][lL]$")
migrations := []migration{}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
return fmt.Errorf("embedded sql migration '%s' is a directory", name)
}
matches := pattern.FindStringSubmatch(name)
if matches == nil {
return fmt.Errorf("embedded sql migration '%s' does not match the filename pattern", name)
}
if len(matches) != 3 {
return fmt.Errorf("embedded sql migration '%s' does not have the correct number of submatches", name)
}
version, err := strconv.Atoi(matches[1])
if err != nil { if err != nil {
return fmt.Errorf("failed to run V1 init script") return fmt.Errorf("failed to parse version number of migration '%s': %w", name, err)
}
migName := matches[2]
file := path.Join("sql_schema", name)
migrations = append(migrations, migration{
file: file,
version: version,
name: migName,
})
}
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].version < migrations[j].version
})
for _, migration := range migrations {
if version >= migration.version {
s.log.Debugf("Skipping migration version %d because current version %d is not smaller.", migration.version, version)
continue
}
migContent, err := sqlSchema.ReadFile(migration.file)
if err != nil {
return fmt.Errorf("failed to read embedded migration '%s': %w", migration.file, err)
}
err = func() (err error) {
rollback := sqlitex.Save(conn)
defer func() {
if err != nil {
s.log.Info("Rolling back last migration attempt.")
}
rollback(&err)
}()
s.log.Infof("Executing migration V%d - %s", migration.version, migration.name)
err = sqlitex.ExecScript(conn, string(migContent))
if err != nil {
return fmt.Errorf("failed to execute migration '%s': %w", migration.name, err)
}
err = sqlitex.Exec(conn, fmt.Sprintf("PRAGMA user_version = %d", migration.version), nil)
if err != nil {
return fmt.Errorf("failed to set user_version in db: %w", err)
}
err = sqlitex.Exec(conn, "PRAGMA user_version", func(stmt *sqlite.Stmt) error {
version = stmt.ColumnInt(0)
return nil
})
if err != nil {
return fmt.Errorf("failed to get user_version: %w", err)
}
s.log.Infof("Migrated db to version: %d", version)
return nil
}()
if err != nil {
return err
} }
} }
if version <= 1 { return nil
s.log.Debugf("Running db migration V2") }
err = sqlitex.ExecScript(conn, sql_v2)
if err != nil { func (s *Server) getLatestVersion(v *semver.Version) (*models.AppVersionInfo, error) {
return fmt.Errorf("failed to run V2 init script") conn := s.getDbConn()
defer s.dbpool.Put(conn)
var latest *models.AppVersionInfo
versions, err := s.getAppVersions()
if err != nil {
return nil, fmt.Errorf("failed to retrieve app versions from db: %w", err)
}
for _, ver := range versions {
if (v == nil || v.LessThan(ver.Version)) && (latest == nil || latest.Version.LessThan(ver.Version)) {
latest = ver
} }
} }
return latest, nil
}
func (s *Server) getAppVersions() ([]*models.AppVersionInfo, error) {
conn := s.getDbConn()
defer s.dbpool.Put(conn)
versions := []*models.AppVersionInfo{}
err := sqlitex.Exec(conn, "select version, address from app_versions", func(stmt *sqlite.Stmt) error {
verStr := stmt.ColumnText(0)
addr := stmt.ColumnText(1)
ver, err := semver.NewVersion(verStr)
if err != nil {
return fmt.Errorf("failed to parse version: %w", err)
}
versions = append(versions, &models.AppVersionInfo{
Version: *ver,
Address: addr,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to insert/retrieve user from db: %w", err)
}
return versions, nil
}
func (s *Server) putAppVersion(versionInfo *models.AppVersionInfo) error {
conn := s.getDbConn()
defer s.dbpool.Put(conn)
err := sqlitex.Exec(conn, "insert into app_versions(version, address) values(?, ?) on conflict(version) do update set address = excluded.address", nil, versionInfo.Version.String(), versionInfo.Address)
if err != nil {
return fmt.Errorf("failed to insert app version into db: %w", err)
}
return nil return nil
} }
@ -300,7 +441,7 @@ func (s *Server) handshake(hc models.HandshakeChallenge) (models.UserID, error)
var id *int64 var id *int64
if hc.Exists { if hc.Exists {
err = sqlitex.Exec(conn, "select id from users where name = ?", func(stmt *sqlite.Stmt) error { err = sqlitex.Exec(conn, "select id from users where name = ?", func(stmt *sqlite.Stmt) error {
id = ptrInt64(stmt.ColumnInt64(0)) id = ptr(stmt.ColumnInt64(0))
return nil return nil
}, hc.Name) }, hc.Name)
if sqlite.ErrCode(err) != sqlite.SQLITE_OK { if sqlite.ErrCode(err) != sqlite.SQLITE_OK {
@ -314,7 +455,7 @@ func (s *Server) handshake(hc models.HandshakeChallenge) (models.UserID, error)
} }
} else { } else {
err = sqlitex.Exec(conn, "insert into users(name) values(?)", func(stmt *sqlite.Stmt) error { err = sqlitex.Exec(conn, "insert into users(name) values(?)", func(stmt *sqlite.Stmt) error {
id = ptrInt64(stmt.ColumnInt64(0)) id = ptr(stmt.ColumnInt64(0))
return nil return nil
}, hc.Name) }, hc.Name)
if sqlite.ErrCode(err) == sqlite.SQLITE_CONSTRAINT_UNIQUE { if sqlite.ErrCode(err) == sqlite.SQLITE_CONSTRAINT_UNIQUE {
@ -323,7 +464,7 @@ func (s *Server) handshake(hc models.HandshakeChallenge) (models.UserID, error)
if sqlite.ErrCode(err) != sqlite.SQLITE_OK { if sqlite.ErrCode(err) != sqlite.SQLITE_OK {
return 0, err return 0, err
} }
id = ptrInt64(conn.LastInsertRowID()) id = ptr(conn.LastInsertRowID())
s.requestCheckpoint() s.requestCheckpoint()
} }
return *id, nil return *id, nil
@ -925,7 +1066,7 @@ func (s *Server) getPhoto(featureID models.FeatureID, photoID models.FeaturePhot
if found { if found {
return fmt.Errorf("multiple photos returned for feature id %d, photo id %d", featureID, photoID) return fmt.Errorf("multiple photos returned for feature id %d, photo id %d", featureID, photoID)
} }
contentType = ptrString(stmt.ColumnText(0)) contentType = ptr(stmt.ColumnText(0))
data = make([]byte, stmt.ColumnLen(1)) data = make([]byte, stmt.ColumnLen(1))
stmt.ColumnBytes(1, data) stmt.ColumnBytes(1, data)
found = true found = true

View File

@ -21,5 +21,3 @@ CREATE TABLE feature_photos (
thumbnail_contents blob NOT NULL, thumbnail_contents blob NOT NULL,
contents blob NOT NULL contents blob NOT NULL
); );
PRAGMA user_version = 1;

View File

@ -3,5 +3,3 @@ CREATE TABLE proposals (
description text NOT NULL, description text NOT NULL,
how text NOT NULL how text NOT NULL
); );
PRAGMA user_version = 2;

View File

@ -0,0 +1,4 @@
CREATE TABLE app_versions (
version text NOT NULL PRIMARY KEY,
address text NOT NULL
);

View File

@ -13,15 +13,7 @@ import (
"cernobor.cz/oko-server/models" "cernobor.cz/oko-server/models"
) )
func ptrInt(x int) *int { func ptr[T any](x T) *T {
return &x
}
func ptrInt64(x int64) *int64 {
return &x
}
func ptrString(x string) *string {
return &x return &x
} }