diff --git a/go.mod b/go.mod index 3e151c4..edd720e 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,15 @@ go 1.19 require ( 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/paulmach/go.geojson v1.4.0 github.com/sirupsen/logrus v1.8.1 ) 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/go-playground/locales v0.14.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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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 golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 diff --git a/go.sum b/go.sum index 1b5c38d..acaebf7 100644 --- a/go.sum +++ b/go.sum @@ -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.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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.0.0-20211210015813-553bc514bbdf/go.mod h1:rlKkU0/sjOysMB+dvVI+b90UyzmD5alQK7KlhJplVrg= +github.com/brendan-ward/mbtiles-go v0.1.1-0.20220129145719-67a7dabdbaab h1:KC2TqbUmfkIrTwVpkH1ysJiZicPoSyb6chH3JxKHqBM= +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.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 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-20211011173535-cb28da3451f1/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.1/go.mod h1:efp5wvQOhIhElvcXrFhXUw+k43dwn9EfdACvXBwHy7o= +github.com/consbio/mbtileserver v0.8.2 h1:iXOBkCp4r/7Z7wC4sw6dYkFAJnuyVpQMu0dbT+RHSxE= +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-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 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/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= diff --git a/models/models.go b/models/models.go index e94927a..f61e960 100644 --- a/models/models.go +++ b/models/models.go @@ -4,6 +4,7 @@ import ( "io" "time" + "github.com/coreos/go-semver/semver" geojson "github.com/paulmach/go.geojson" ) @@ -40,6 +41,11 @@ type BuildInfo struct { // transport objects +type AppVersionInfo struct { + Version semver.Version `json:"version"` + Address string `json:"address"` +} + type Coords struct { Lat float64 `json:"lat"` Lng float64 `json:"lng"` diff --git a/server/constants.go b/server/constants.go index f88102b..9120826 100644 --- a/server/constants.go +++ b/server/constants.go @@ -5,6 +5,7 @@ const ( URIBuildInfo = "/build-info" URIHardFail = "/hard-fail" URISoftFail = "/soft-fail" + URIAppVersions = "/app-versions" URIReinit = "/reinit" URIMapPack = "/mappack" URIHandshake = "/handshake" @@ -16,4 +17,6 @@ const ( URITileserverRoot = "/tileserver" URITileserver = URITileserverRoot + "/*x" URITileTemplate = URITileserverRoot + "/map/tiles/{z}/{x}/{y}.pbf" + + AppName = "OKO" ) diff --git a/server/handling.go b/server/handling.go index d0bd96f..2cf160e 100644 --- a/server/handling.go +++ b/server/handling.go @@ -13,14 +13,22 @@ import ( "cernobor.cz/oko-server/errs" "cernobor.cz/oko-server/models" + "github.com/coreos/go-semver/semver" "github.com/gin-gonic/gin" + "github.com/mssola/user_agent" "github.com/sirupsen/logrus" ) func internalError(gc *gin.Context, err error) { + gc.Error(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 { gin.SetMode(gin.ReleaseMode) 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 - router.GET(URIPing, func(gc *gin.Context) { - gc.Status(http.StatusNoContent) - }) router.GET(URIBuildInfo, func(gc *gin.Context) { gc.JSON(http.StatusOK, models.BuildInfo{ VersionHash: s.config.VersionHash, @@ -93,26 +114,72 @@ func (s *Server) setupRouter() *gin.Engine { router.GET(URISoftFail, func(gc *gin.Context) { 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) - // 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 } +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) { err := s.initDB(true) if err != nil { @@ -130,7 +197,7 @@ func (s *Server) handlePOSTHandshake(gc *gin.Context) { var hs models.HandshakeChallenge err := gc.ShouldBindJSON(&hs) if err != nil { - gc.String(http.StatusBadRequest, fmt.Sprintf("malformed handshake challenge: %v", err)) + badRequest(gc, fmt.Errorf("malformed handshake challenge: %w", err)) return } @@ -205,7 +272,7 @@ func (s *Server) handlePOSTData(gc *gin.Context) { case "multipart/form-data": s.handlePOSTDataMultipart(gc) 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 err := gc.ShouldBindJSON(&data) if err != nil { - gc.String(http.StatusBadRequest, fmt.Sprintf("malformed data: %v", err)) + badRequest(gc, fmt.Errorf("malformed data: %w", err)) return } 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 } 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 } @@ -238,7 +305,7 @@ func (s *Server) handlePOSTDataJSON(gc *gin.Context) { func (s *Server) handlePOSTDataMultipart(gc *gin.Context) { form, err := gc.MultipartForm() if err != nil { - gc.String(http.StatusBadRequest, "malformed multipart/form-data content") + badRequest(gc, fmt.Errorf("malformed multipart/form-data content")) return } @@ -246,11 +313,11 @@ func (s *Server) handlePOSTDataMultipart(gc *gin.Context) { if !ok { dataFile, ok := form.File["data"] 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 } 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 } df, err := dataFile[0].Open() @@ -266,26 +333,26 @@ func (s *Server) handlePOSTDataMultipart(gc *gin.Context) { dataStr = []string{string(dataBytes)} } 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 } var data models.Update err = json.Unmarshal([]byte(dataStr[0]), &data) if err != nil { - gc.String(http.StatusBadRequest, "malformed 'data' value: %v", err) + badRequest(gc, fmt.Errorf("malformed 'data' value: %w", err)) return } 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 } photos := make(map[string]models.Photo, len(form.File)) for name, fh := range form.File { 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 } var photo models.Photo @@ -303,7 +370,7 @@ func (s *Server) handlePOSTDataMultipart(gc *gin.Context) { if err != nil { var e *errs.ErrUnsupportedContentType if errors.As(err, &e) { - gc.String(http.StatusBadRequest, e.Error()) + badRequest(gc, e) return } 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) { reqFeatureID, err := strconv.Atoi(gc.Param("feature")) if err != nil { - gc.String(http.StatusBadRequest, "malformed feature ID") + badRequest(gc, fmt.Errorf("malformed feature ID")) return } reqPhotoID, err := strconv.Atoi(gc.Param("photo")) if err != nil { - gc.String(http.StatusBadRequest, "malformed photo ID") + badRequest(gc, fmt.Errorf("malformed photo ID")) return } diff --git a/server/server.go b/server/server.go index 2a4a47b..a0766d3 100644 --- a/server/server.go +++ b/server/server.go @@ -11,6 +11,10 @@ import ( "net/http" "net/url" "os" + "path" + "regexp" + "sort" + "strconv" "time" "cernobor.cz/oko-server/errs" @@ -18,6 +22,7 @@ import ( "crawshaw.io/sqlite" "crawshaw.io/sqlite/sqlitex" mbsh "github.com/consbio/mbtileserver/handlers" + "github.com/coreos/go-semver/semver" geojson "github.com/paulmach/go.geojson" "github.com/sirupsen/logrus" ) @@ -346,9 +351,9 @@ func (s *Server) migrateDb(conn *sqlite.Conn) error { } err = sqlitex.Exec(conn, fmt.Sprintf("PRAGMA user_version = %d", migration.version), nil) - if err != 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) @@ -356,7 +361,7 @@ func (s *Server) migrateDb(conn *sqlite.Conn) error { }) if err != nil { return fmt.Errorf("failed to get user_version: %w", err) - } + } s.log.Infof("Migrated db to version: %d", version) return nil @@ -366,6 +371,61 @@ func (s *Server) migrateDb(conn *sqlite.Conn) error { } } return nil +} + +func (s *Server) getLatestVersion(v *semver.Version) (*models.AppVersionInfo, error) { + 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 diff --git a/server/sql_schema/V3_app_versions.sql b/server/sql_schema/V3_app_versions.sql new file mode 100644 index 0000000..bdf5158 --- /dev/null +++ b/server/sql_schema/V3_app_versions.sql @@ -0,0 +1,4 @@ +CREATE TABLE app_versions ( + version text NOT NULL PRIMARY KEY, + address text NOT NULL +);