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
This commit is contained in:
zegkljan
2022-09-23 00:59:20 +02:00
parent c9377b04fc
commit 7c8a430f49
7 changed files with 187 additions and 42 deletions
+3
View File
@@ -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"
)
+100 -33
View File
@@ -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
}
+63 -3
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
CREATE TABLE app_versions (
version text NOT NULL PRIMARY KEY,
address text NOT NULL
);