mirror of
https://github.com/Cernobor/oko-server.git
synced 2025-02-24 08:27:17 +00:00
Collecting usage info, change sqlite lib.
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
* X-User-ID header is processed to get user ID. * Time of last request for a user is saved into DB. * Time of last upload and download is stored for a user. * Added DB migration to add columns into users table to store the times and app version. * Backward fix of datatype of the deadline column in features table. * Switched from crawshaw.io/sqlite to zombiezen.com/go/sqlite. * Refactored DB handling. * Used migration routine from zombiezen in favour of manual one. * Runtime DB reinit simply deletes the db file and initializes the db anew. Fix #6
This commit is contained in:
parent
63e79c657c
commit
8440e3b7d7
@ -125,6 +125,37 @@
|
|||||||
"required": ["id", "name"],
|
"required": ["id", "name"],
|
||||||
"description": "A user in the system."
|
"description": "A user in the system."
|
||||||
},
|
},
|
||||||
|
"UserInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "User ID."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "User name."
|
||||||
|
},
|
||||||
|
"app_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Version of the app last used by the user."
|
||||||
|
},
|
||||||
|
"last_seen_time": {
|
||||||
|
"anyOf": [{"$ref": "#/components/schemas/LocalDateTime"}],
|
||||||
|
"description": "Time of last contact (any) with the server."
|
||||||
|
},
|
||||||
|
"last_upload_time": {
|
||||||
|
"anyOf": [{"$ref": "#/components/schemas/LocalDateTime"}],
|
||||||
|
"description": "Time of last data upload."
|
||||||
|
},
|
||||||
|
"last_download_time": {
|
||||||
|
"anyOf": [{"$ref": "#/components/schemas/LocalDateTime"}],
|
||||||
|
"description": "Time of last data download."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id", "name", "app_version", "last_seen_time", "last_upload_time", "last_download_time"],
|
||||||
|
"description": "Extended info about a user."
|
||||||
|
},
|
||||||
"Feature": {
|
"Feature": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -312,11 +343,22 @@
|
|||||||
"required": ["version_hash", "build_time"],
|
"required": ["version_hash", "build_time"],
|
||||||
"description": "Server build info."
|
"description": "Server build info."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"UserID": {
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-User-ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"security": [],
|
"security": [],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/ping" : {
|
"/ping" : {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ping",
|
"operationId": "ping",
|
||||||
"tags": ["app"],
|
"tags": ["app"],
|
||||||
@ -342,6 +384,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/handshake": {
|
"/handshake": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "handshake",
|
"operationId": "handshake",
|
||||||
"tags": ["app"],
|
"tags": ["app"],
|
||||||
@ -384,6 +427,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/data": {
|
"/data": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getData",
|
"operationId": "getData",
|
||||||
"tags": ["app"],
|
"tags": ["app"],
|
||||||
@ -500,6 +544,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/data/people": {
|
"/data/people": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getPeople",
|
"operationId": "getPeople",
|
||||||
"tags": ["app"],
|
"tags": ["app"],
|
||||||
@ -523,6 +568,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/data/features": {
|
"/data/features": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getFeatures",
|
"operationId": "getFeatures",
|
||||||
"tags": ["app"],
|
"tags": ["app"],
|
||||||
@ -547,6 +593,7 @@
|
|||||||
},
|
},
|
||||||
"/data/features/{featureID}/photos/{photoID}": {
|
"/data/features/{featureID}/photos/{photoID}": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{"$ref": "#/components/parameters/UserID"},
|
||||||
{
|
{
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"name": "featureID",
|
"name": "featureID",
|
||||||
@ -599,6 +646,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/data/proposals": {
|
"/data/proposals": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getProposals",
|
"operationId": "getProposals",
|
||||||
"tags": ["app"],
|
"tags": ["app"],
|
||||||
@ -622,6 +670,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/mappack": {
|
"/mappack": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getTilePack",
|
"operationId": "getTilePack",
|
||||||
"tags": ["app"],
|
"tags": ["app"],
|
||||||
@ -634,6 +683,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/build-info": {
|
"/build-info": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getBuildInfo",
|
"operationId": "getBuildInfo",
|
||||||
"tags": ["debug", "utils"],
|
"tags": ["debug", "utils"],
|
||||||
@ -653,6 +703,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/hard-fail": {
|
"/hard-fail": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "hardFail",
|
"operationId": "hardFail",
|
||||||
"tags": ["debug"],
|
"tags": ["debug"],
|
||||||
@ -665,6 +716,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/soft-fail": {
|
"/soft-fail": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "softFail",
|
"operationId": "softFail",
|
||||||
"tags": ["debug"],
|
"tags": ["debug"],
|
||||||
@ -689,6 +741,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/app-versions": {
|
"/app-versions": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAppVersions",
|
"operationId": "getAppVersions",
|
||||||
"tags": ["utils", "debug"],
|
"tags": ["utils", "debug"],
|
||||||
@ -736,6 +789,7 @@
|
|||||||
},
|
},
|
||||||
"/app-versions/{version}": {
|
"/app-versions/{version}": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{"$ref": "#/components/parameters/UserID"},
|
||||||
{
|
{
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"name": "version",
|
"name": "version",
|
||||||
@ -786,6 +840,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/reinit": {
|
"/reinit": {
|
||||||
|
"parameters": [{"$ref": "#/components/parameters/UserID"}],
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "reinitDb",
|
"operationId": "reinitDb",
|
||||||
"tags": ["debug", "utils"],
|
"tags": ["debug", "utils"],
|
||||||
@ -796,6 +851,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/usage-info": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getUsageInfo",
|
||||||
|
"tags": ["utils", "debug"],
|
||||||
|
"summary": "Similar to /data/people, but used app version and times of last contact, upload, and download are retreived as well.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "User usage info.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/UserInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
10
go.mod
10
go.mod
@ -3,24 +3,32 @@ module cernobor.cz/oko-server
|
|||||||
go 1.20
|
go 1.20
|
||||||
|
|
||||||
require (
|
require (
|
||||||
crawshaw.io/sqlite v0.3.3-0.20211227050848-2cdb5c1a86a1
|
|
||||||
github.com/consbio/mbtileserver v0.9.0
|
github.com/consbio/mbtileserver v0.9.0
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/paulmach/go.geojson v1.4.0
|
github.com/paulmach/go.geojson v1.4.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
zombiezen.com/go/sqlite v0.13.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
crawshaw.io/sqlite v0.3.3-0.20211227050848-2cdb5c1a86a1 // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.10.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.24.1 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.6.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
20
go.sum
20
go.sum
@ -17,6 +17,8 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
@ -33,9 +35,11 @@ github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
|
|||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@ -58,6 +62,9 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
|
|||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@ -120,7 +127,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
|||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||||
@ -130,4 +136,14 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||||
|
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
|
||||||
|
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.0 h1:MWTFBI5H1WLnXpNBh/BTruBVqzzoh28DA0iOnlkkRaM=
|
||||||
|
modernc.org/sqlite v1.23.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
zombiezen.com/go/sqlite v0.13.0 h1:iEeyVqcm3fk5PCA8OQBhBxPnqrP4yYuVJBF+XZpSnOE=
|
||||||
|
zombiezen.com/go/sqlite v0.13.0/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=
|
||||||
|
@ -19,6 +19,14 @@ type User struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserInfo struct {
|
||||||
|
User
|
||||||
|
AppVersion *semver.Version `json:"app_version"`
|
||||||
|
LastSeenTime *time.Time `json:"last_seen_time"`
|
||||||
|
LastUploadTime *time.Time `json:"last_upload_time"`
|
||||||
|
LastDownloadTime *time.Time `json:"last_download_time"`
|
||||||
|
}
|
||||||
|
|
||||||
type Feature struct {
|
type Feature struct {
|
||||||
// ID is an ID of the feature.
|
// ID is an ID of the feature.
|
||||||
// When the feature is submitted by a client for creation (i.e. in Update.Create) it is considered a 'local' ID which must be unique across all submitted features.
|
// When the feature is submitted by a client for creation (i.e. in Update.Create) it is considered a 'local' ID which must be unique across all submitted features.
|
||||||
|
@ -20,5 +20,6 @@ const (
|
|||||||
URITileserver = URITileserverRoot + "/*x"
|
URITileserver = URITileserverRoot + "/*x"
|
||||||
URITileTemplate = URITileserverRoot + "/map/tiles/{z}/{x}/{y}.pbf"
|
URITileTemplate = URITileserverRoot + "/map/tiles/{z}/{x}/{y}.pbf"
|
||||||
|
|
||||||
AppName = "OKO"
|
AppName = "OKO"
|
||||||
|
UserIDHeader = "X-User-ID"
|
||||||
)
|
)
|
||||||
|
229
server/db.go
Normal file
229
server/db.go
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zombiezen.com/go/sqlite"
|
||||||
|
"zombiezen.com/go/sqlite/sqlitemigration"
|
||||||
|
"zombiezen.com/go/sqlite/sqlitex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) cleanupDb() {
|
||||||
|
close(s.checkpointNotice)
|
||||||
|
s.log.Info("Closing db connection pool...")
|
||||||
|
s.dbpool.Close()
|
||||||
|
|
||||||
|
// manually force truncate checkpoint
|
||||||
|
conn, err := sqlite.OpenConn(fmt.Sprintf("file:%s", s.config.DbPath), 0)
|
||||||
|
if err != nil {
|
||||||
|
s.log.WithError(err).Error("Failed to open connection for final checkpoint.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = sqlitex.Execute(conn, "vacuum", nil)
|
||||||
|
if err != nil {
|
||||||
|
s.log.WithError(err).Error("Failed to vacuum db.")
|
||||||
|
}
|
||||||
|
s.checkpointDb(conn, true)
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getDbConn() *sqlite.Conn {
|
||||||
|
conn, err := s.dbpool.Get(s.ctx)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) returnDbConn(conn *sqlite.Conn) {
|
||||||
|
s.dbpool.Put(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withDbConn[T any](s *Server, f func(conn *sqlite.Conn) T) T {
|
||||||
|
conn := s.getDbConn()
|
||||||
|
defer s.returnDbConn(conn)
|
||||||
|
return f(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) checkpointDb(conn *sqlite.Conn, truncate bool) {
|
||||||
|
var query string
|
||||||
|
if truncate {
|
||||||
|
query = "PRAGMA wal_checkpoint(TRUNCATE)"
|
||||||
|
} else {
|
||||||
|
query = "PRAGMA wal_checkpoint(RESTART)"
|
||||||
|
}
|
||||||
|
stmt, _, err := conn.PrepareTransient(query)
|
||||||
|
if err != nil {
|
||||||
|
s.log.WithError(err).Error("Failed to prepare checkpoint query.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stmt.Finalize()
|
||||||
|
|
||||||
|
has, err := stmt.Step()
|
||||||
|
if err != nil {
|
||||||
|
s.log.WithError(err).Error("Failed to step through checkpoint query.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
s.log.Error("Checkpoint query returned no rows.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := stmt.ColumnInt(0)
|
||||||
|
noWalPages := stmt.ColumnInt(1)
|
||||||
|
noReclaimedPages := stmt.ColumnInt(2)
|
||||||
|
if blocked == 1 {
|
||||||
|
s.log.Warn("Checkpoint query was blocked.")
|
||||||
|
}
|
||||||
|
s.log.Debugf("Checkpoint complete. %d pages written to WAL, %d pages written back to DB.", noWalPages, noReclaimedPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setupDB() error {
|
||||||
|
s.dbAvailable.Store(false)
|
||||||
|
s.log.Debugf("Using db %s", s.config.DbPath)
|
||||||
|
|
||||||
|
ready := make(chan struct{})
|
||||||
|
migErr := make(chan error)
|
||||||
|
s.dbpool = sqlitemigration.NewPool(fmt.Sprintf("file:%s", s.config.DbPath), sqlSchema, sqlitemigration.Options{
|
||||||
|
PoolSize: 10,
|
||||||
|
PrepareConn: func(conn *sqlite.Conn) error {
|
||||||
|
return sqlitex.ExecuteTransient(conn, "PRAGMA foreign_keys = ON;", nil)
|
||||||
|
},
|
||||||
|
OnReady: func() {
|
||||||
|
close(ready)
|
||||||
|
},
|
||||||
|
OnError: func(err error) {
|
||||||
|
migErr <- err
|
||||||
|
},
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case <-ready:
|
||||||
|
case err := <-migErr:
|
||||||
|
return fmt.Errorf("error during db migration: %w", err)
|
||||||
|
}
|
||||||
|
s.checkpointNotice = make(chan struct{})
|
||||||
|
|
||||||
|
// aggressively checkpoint the database on idle times
|
||||||
|
go func() {
|
||||||
|
s.log.Debug("Starting manual restart checkpointing.")
|
||||||
|
defer s.log.Debug("Manual restart checkpointing stopped.")
|
||||||
|
delay := time.Minute * 15
|
||||||
|
var (
|
||||||
|
timer <-chan time.Time
|
||||||
|
ok bool
|
||||||
|
)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok = <-s.checkpointNotice:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer = time.After(delay)
|
||||||
|
case <-timer:
|
||||||
|
withDbConn(s, func(conn *sqlite.Conn) any {
|
||||||
|
s.checkpointDb(conn, false)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
s.dbAvailable.Store(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) requestCheckpoint() {
|
||||||
|
go func() {
|
||||||
|
s.checkpointNotice <- struct{}{}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) reinitDb() error {
|
||||||
|
s.log.Debug("Reinitializing db.")
|
||||||
|
s.dbAvailable.Store(false)
|
||||||
|
defer s.dbAvailable.Store(true)
|
||||||
|
close(s.checkpointNotice)
|
||||||
|
err := s.dbpool.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to close db: %w", err)
|
||||||
|
}
|
||||||
|
s.log.Debug("Removing main db file.")
|
||||||
|
err = os.Remove(s.config.DbPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove db file %s: %w", s.config.DbPath, err)
|
||||||
|
}
|
||||||
|
s.log.Debug("Removing WAL file.")
|
||||||
|
err = os.Remove(s.config.DbPath + "-wal")
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove db-wal file %s-wal: %w", s.config.DbPath, err)
|
||||||
|
}
|
||||||
|
s.log.Debug("Initializing db.")
|
||||||
|
err = s.setupDB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to setup db during reinit")
|
||||||
|
}
|
||||||
|
s.log.Debug("DB reinitialized.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL schema
|
||||||
|
|
||||||
|
//go:embed sql_schema/V*.sql
|
||||||
|
var sqlSchemaFiles embed.FS
|
||||||
|
var sqlSchema sqlitemigration.Schema = func() sqlitemigration.Schema {
|
||||||
|
type migration struct {
|
||||||
|
content string
|
||||||
|
version int
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := sqlSchemaFiles.ReadDir("sql_schema")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to read sql_schema migrations: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := regexp.MustCompile("^V([0-9]+)_(.*)[.][sS][qQ][lL]$")
|
||||||
|
migrations := []*migration{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() {
|
||||||
|
panic(fmt.Errorf("embedded sql migration '%s' is a directory", name))
|
||||||
|
}
|
||||||
|
matches := pattern.FindStringSubmatch(name)
|
||||||
|
if matches == nil {
|
||||||
|
panic(fmt.Errorf("embedded sql migration '%s' does not match the filename pattern", name))
|
||||||
|
}
|
||||||
|
if len(matches) != 3 {
|
||||||
|
panic(fmt.Errorf("embedded sql migration '%s' does not have the correct number of submatches", name))
|
||||||
|
}
|
||||||
|
version, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to parse version number of migration '%s': %w", name, err))
|
||||||
|
}
|
||||||
|
migName := matches[2]
|
||||||
|
file := path.Join("sql_schema", name)
|
||||||
|
content, err := sqlSchemaFiles.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to read embedded migration %s", entry.Name()))
|
||||||
|
}
|
||||||
|
migrations = append(migrations, &migration{
|
||||||
|
content: string(content),
|
||||||
|
version: version,
|
||||||
|
name: migName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(migrations, func(i, j int) bool {
|
||||||
|
return migrations[i].version < migrations[j].version
|
||||||
|
})
|
||||||
|
|
||||||
|
return sqlitemigration.Schema{
|
||||||
|
Migrations: Map(func(m *migration) string { return m.content }, migrations),
|
||||||
|
}
|
||||||
|
}()
|
@ -17,6 +17,8 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mssola/user_agent"
|
"github.com/mssola/user_agent"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
"zombiezen.com/go/sqlite"
|
||||||
|
"zombiezen.com/go/sqlite/sqlitex"
|
||||||
)
|
)
|
||||||
|
|
||||||
func internalError(gc *gin.Context, err error) {
|
func internalError(gc *gin.Context, err error) {
|
||||||
@ -39,50 +41,104 @@ func (s *Server) setupRouter() *gin.Engine {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
hostname = "unknown"
|
hostname = "unknown"
|
||||||
}
|
}
|
||||||
router.Use(func(gc *gin.Context) {
|
router.Use(
|
||||||
path := gc.Request.URL.Path
|
func(gc *gin.Context) {
|
||||||
start := time.Now()
|
path := gc.Request.URL.Path
|
||||||
gc.Next()
|
start := time.Now()
|
||||||
stop := time.Since(start)
|
gc.Next()
|
||||||
latency := int(math.Ceil(float64(stop.Nanoseconds()) / 1_000_000.0))
|
stop := time.Since(start)
|
||||||
statusCode := gc.Writer.Status()
|
latency := int(math.Ceil(float64(stop.Nanoseconds()) / 1_000_000.0))
|
||||||
clientIP := gc.ClientIP()
|
statusCode := gc.Writer.Status()
|
||||||
clientUserAgent := gc.Request.UserAgent()
|
clientIP := gc.ClientIP()
|
||||||
referer := gc.Request.Referer()
|
clientUserAgent := gc.Request.UserAgent()
|
||||||
dataLength := gc.Writer.Size()
|
userId := gc.GetHeader(UserIDHeader)
|
||||||
if dataLength < 0 {
|
referer := gc.Request.Referer()
|
||||||
dataLength = 0
|
dataLength := gc.Writer.Size()
|
||||||
}
|
if dataLength < 0 {
|
||||||
entry := s.log.WithFields(logrus.Fields{
|
dataLength = 0
|
||||||
"hostname": hostname,
|
|
||||||
"statusCode": statusCode,
|
|
||||||
"latency": latency,
|
|
||||||
"clientIP": clientIP,
|
|
||||||
"method": gc.Request.Method,
|
|
||||||
"path": path,
|
|
||||||
"referer": referer,
|
|
||||||
"dataLength": dataLength,
|
|
||||||
"userAgent": clientUserAgent,
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(gc.Errors) > 0 {
|
|
||||||
entry.Error(gc.Errors.ByType(gin.ErrorTypePrivate).String())
|
|
||||||
} else {
|
|
||||||
msg := fmt.Sprintf(
|
|
||||||
"%s - %s [%s] \"%s %s\" %d %d \"%s\" \"%s\" (%dms)",
|
|
||||||
clientIP, hostname, time.Now().Format(time.RFC3339), gc.Request.Method, path, statusCode, dataLength, referer, clientUserAgent, latency,
|
|
||||||
)
|
|
||||||
if statusCode >= 500 {
|
|
||||||
entry.Error(msg)
|
|
||||||
} else if statusCode >= 400 {
|
|
||||||
entry.Warn(msg)
|
|
||||||
} else if path == URIPing {
|
|
||||||
entry.Debug(msg)
|
|
||||||
} else {
|
|
||||||
entry.Info(msg)
|
|
||||||
}
|
}
|
||||||
}
|
entry := s.log.WithFields(logrus.Fields{
|
||||||
})
|
"hostname": hostname,
|
||||||
|
"statusCode": statusCode,
|
||||||
|
"latency": latency,
|
||||||
|
"clientIP": clientIP,
|
||||||
|
"method": gc.Request.Method,
|
||||||
|
"path": path,
|
||||||
|
"referer": referer,
|
||||||
|
"dataLength": dataLength,
|
||||||
|
"userAgent": clientUserAgent,
|
||||||
|
"userID": userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(gc.Errors) > 0 {
|
||||||
|
entry.Error(gc.Errors.ByType(gin.ErrorTypePrivate).String())
|
||||||
|
} else {
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"%s - %s [%s] \"%s %s\" %d %d \"%s\" \"%s\" (%dms)",
|
||||||
|
clientIP, hostname, time.Now().Format(time.RFC3339), gc.Request.Method, path, statusCode, dataLength, referer, clientUserAgent, latency,
|
||||||
|
)
|
||||||
|
if statusCode >= 500 {
|
||||||
|
entry.Error(msg)
|
||||||
|
} else if statusCode >= 400 {
|
||||||
|
entry.Warn(msg)
|
||||||
|
} else if path == URIPing {
|
||||||
|
entry.Debug(msg)
|
||||||
|
} else {
|
||||||
|
entry.Info(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func(gc *gin.Context) {
|
||||||
|
if !s.dbAvailable.Load() {
|
||||||
|
gc.AbortWithError(http.StatusServiceUnavailable, fmt.Errorf("server database is not ready/available"))
|
||||||
|
gc.Header("Retry-After", "60")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gc.Next()
|
||||||
|
},
|
||||||
|
func(gc *gin.Context) {
|
||||||
|
userIdStr := gc.GetHeader(UserIDHeader)
|
||||||
|
userIdPresent := userIdStr != ""
|
||||||
|
var userId int64
|
||||||
|
var userIdErr error
|
||||||
|
if userIdPresent {
|
||||||
|
userId, userIdErr = strconv.ParseInt(userIdStr, 10, 0)
|
||||||
|
}
|
||||||
|
appVersion, appVersionErr := extractAppVersion(gc)
|
||||||
|
if userIdErr != nil {
|
||||||
|
gc.Error(fmt.Errorf("malformed %s: %w", UserIDHeader, userIdErr))
|
||||||
|
} else if userIdPresent {
|
||||||
|
gc.Set("uid", userId)
|
||||||
|
var err error
|
||||||
|
if appVersion == nil {
|
||||||
|
err = withDbConn(s, func(conn *sqlite.Conn) error {
|
||||||
|
err := sqlitex.Execute(conn, "update users set last_seen_time = ? where id = ?", &sqlitex.ExecOptions{
|
||||||
|
Args: []interface{}{time.Now().Unix(), userId},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
err = withDbConn(s, func(conn *sqlite.Conn) error {
|
||||||
|
err := sqlitex.Execute(conn, "update users set app_version = ?, last_seen_time = ? where id = ?", &sqlitex.ExecOptions{
|
||||||
|
Args: []interface{}{appVersion, time.Now().Unix(), userId},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
gc.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to store user data into db: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if appVersion != nil {
|
||||||
|
gc.Set("app-version", appVersion)
|
||||||
|
}
|
||||||
|
if appVersionErr != nil {
|
||||||
|
gc.Error(appVersionErr)
|
||||||
|
gc.Set("app-version-error", appVersionErr.Error())
|
||||||
|
}
|
||||||
|
gc.Next()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// tileserver
|
// tileserver
|
||||||
router.GET(URITileserver, gin.WrapH(s.tileserverSvSet.Handler()))
|
router.GET(URITileserver, gin.WrapH(s.tileserverSvSet.Handler()))
|
||||||
@ -118,6 +174,7 @@ func (s *Server) setupRouter() *gin.Engine {
|
|||||||
router.GET(URIAppVersion, s.handleGETAppVersion)
|
router.GET(URIAppVersion, s.handleGETAppVersion)
|
||||||
router.DELETE(URIAppVersion, s.handleDELETEAppVersion)
|
router.DELETE(URIAppVersion, s.handleDELETEAppVersion)
|
||||||
router.POST(URIReinit, s.handlePOSTReset)
|
router.POST(URIReinit, s.handlePOSTReset)
|
||||||
|
router.GET(URIUsageInfo, s.handleGETUsageInfo)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
@ -137,9 +194,19 @@ func extractAppVersion(gc *gin.Context) (*semver.Version, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGETPing(gc *gin.Context) {
|
func (s *Server) handleGETPing(gc *gin.Context) {
|
||||||
version, err := extractAppVersion(gc)
|
versionRaw, exists := gc.Get("app-version")
|
||||||
if err != nil {
|
if !exists {
|
||||||
badRequest(gc, err)
|
versionErr := gc.GetString("app-version-error")
|
||||||
|
if versionErr != "" {
|
||||||
|
badRequest(gc, fmt.Errorf("malformed app version: %v", &versionErr))
|
||||||
|
} else {
|
||||||
|
badRequest(gc, fmt.Errorf("app version not specified"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version, ok := versionRaw.(*semver.Version)
|
||||||
|
if !ok {
|
||||||
|
internalError(gc, fmt.Errorf("malformed app version extracted"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,12 +287,12 @@ func (s *Server) handleDELETEAppVersion(gc *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePOSTReset(gc *gin.Context) {
|
func (s *Server) handlePOSTReset(gc *gin.Context) {
|
||||||
err := s.initDB(true)
|
err := s.reinitDb()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
internalError(gc, err)
|
internalError(gc, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gc.Status(http.StatusOK)
|
gc.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGETTilepack(gc *gin.Context) {
|
func (s *Server) handleGETTilepack(gc *gin.Context) {
|
||||||
@ -268,6 +335,8 @@ func (s *Server) handlePOSTHandshake(gc *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGETData(gc *gin.Context) {
|
func (s *Server) handleGETData(gc *gin.Context) {
|
||||||
|
uidRaw, uidExists := gc.Get("uid")
|
||||||
|
uid, _ := uidRaw.(int64)
|
||||||
accept := gc.GetHeader("Accept")
|
accept := gc.GetHeader("Accept")
|
||||||
if accept == "application/json" {
|
if accept == "application/json" {
|
||||||
data, err := s.getDataOnly()
|
data, err := s.getDataOnly()
|
||||||
@ -276,7 +345,6 @@ func (s *Server) handleGETData(gc *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
gc.JSON(http.StatusOK, data)
|
gc.JSON(http.StatusOK, data)
|
||||||
return
|
|
||||||
} else if accept == "application/zip" {
|
} else if accept == "application/zip" {
|
||||||
file, err := s.getDataWithPhotos()
|
file, err := s.getDataWithPhotos()
|
||||||
defer func() {
|
defer func() {
|
||||||
@ -299,12 +367,25 @@ func (s *Server) handleGETData(gc *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
gc.DataFromReader(http.StatusOK, size, "application/zip", file, nil)
|
gc.DataFromReader(http.StatusOK, size, "application/zip", file, nil)
|
||||||
|
} else {
|
||||||
|
gc.String(http.StatusNotAcceptable, "%s is not acceptable", accept)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
gc.String(http.StatusNotAcceptable, "%s is not acceptable", accept)
|
if uidExists {
|
||||||
|
err := withDbConn(s, func(conn *sqlite.Conn) error {
|
||||||
|
return sqlitex.Execute(conn, "update users set last_download_time = ? where id = ?", &sqlitex.ExecOptions{
|
||||||
|
Args: []interface{}{time.Now().Unix(), uid},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
gc.Error(fmt.Errorf("failed to store last download time: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePOSTData(gc *gin.Context) {
|
func (s *Server) handlePOSTData(gc *gin.Context) {
|
||||||
|
uidRaw, uidExists := gc.Get("uid")
|
||||||
|
uid, _ := uidRaw.(int64)
|
||||||
switch gc.ContentType() {
|
switch gc.ContentType() {
|
||||||
case "application/json":
|
case "application/json":
|
||||||
s.handlePOSTDataJSON(gc)
|
s.handlePOSTDataJSON(gc)
|
||||||
@ -312,6 +393,17 @@ func (s *Server) handlePOSTData(gc *gin.Context) {
|
|||||||
s.handlePOSTDataMultipart(gc)
|
s.handlePOSTDataMultipart(gc)
|
||||||
default:
|
default:
|
||||||
badRequest(gc, fmt.Errorf("unsupported Content-Type"))
|
badRequest(gc, fmt.Errorf("unsupported Content-Type"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if uidExists {
|
||||||
|
err := withDbConn(s, func(conn *sqlite.Conn) error {
|
||||||
|
return sqlitex.Execute(conn, "update users set last_upload_time = ? where id = ?", &sqlitex.ExecOptions{
|
||||||
|
Args: []interface{}{time.Now().Unix(), uid},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
gc.Error(fmt.Errorf("failed to store last upload time: %w", err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -470,3 +562,12 @@ func (s *Server) handleGETDataProposals(gc *gin.Context) {
|
|||||||
}
|
}
|
||||||
gc.JSON(http.StatusOK, proposals)
|
gc.JSON(http.StatusOK, proposals)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGETUsageInfo(gc *gin.Context) {
|
||||||
|
usage, err := s.getUsageInfo(nil)
|
||||||
|
if err != nil {
|
||||||
|
internalError(gc, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gc.JSON(http.StatusOK, usage)
|
||||||
|
}
|
||||||
|
653
server/server.go
653
server/server.go
@ -4,35 +4,30 @@ 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"
|
"sync/atomic"
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/sqlitex"
|
|
||||||
mbsh "github.com/consbio/mbtileserver/handlers"
|
mbsh "github.com/consbio/mbtileserver/handlers"
|
||||||
"github.com/coreos/go-semver/semver"
|
"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"
|
||||||
|
"zombiezen.com/go/sqlite"
|
||||||
|
"zombiezen.com/go/sqlite/sqlitemigration"
|
||||||
|
"zombiezen.com/go/sqlite/sqlitex"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed sql_schema/V*.sql
|
|
||||||
var sqlSchema embed.FS
|
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config ServerConfig
|
config ServerConfig
|
||||||
dbpool *sqlitex.Pool
|
dbpool *sqlitemigration.Pool
|
||||||
|
dbAvailable atomic.Bool
|
||||||
checkpointNotice chan struct{}
|
checkpointNotice chan struct{}
|
||||||
log *logrus.Logger
|
log *logrus.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@ -66,8 +61,13 @@ func (s *Server) Run(ctx context.Context) {
|
|||||||
s.log.SetLevel(logrus.DebugLevel)
|
s.log.SetLevel(logrus.DebugLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.dbAvailable.Store(false)
|
||||||
|
|
||||||
s.ctx = ctx
|
s.ctx = ctx
|
||||||
s.setupDB()
|
err := s.setupDB()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
defer s.cleanupDb()
|
defer s.cleanupDb()
|
||||||
|
|
||||||
s.setupTiles()
|
s.setupTiles()
|
||||||
@ -96,116 +96,6 @@ func (s *Server) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) cleanupDb() {
|
|
||||||
close(s.checkpointNotice)
|
|
||||||
s.log.Info("Closing db connection pool...")
|
|
||||||
s.dbpool.Close()
|
|
||||||
|
|
||||||
// manually force truncate checkpoint
|
|
||||||
conn, err := sqlite.OpenConn(fmt.Sprintf("file:%s", s.config.DbPath), 0)
|
|
||||||
if err != nil {
|
|
||||||
s.log.WithError(err).Error("Failed to open connection for final checkpoint.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = sqlitex.Exec(conn, "vacuum", nil)
|
|
||||||
if err != nil {
|
|
||||||
s.log.WithError(err).Error("Failed to vacuum db.")
|
|
||||||
}
|
|
||||||
s.checkpointDb(conn, true)
|
|
||||||
conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getDbConn() *sqlite.Conn {
|
|
||||||
conn := s.dbpool.Get(s.ctx)
|
|
||||||
_, err := conn.Prep("PRAGMA foreign_keys = ON").Step()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) checkpointDb(conn *sqlite.Conn, truncate bool) {
|
|
||||||
var query string
|
|
||||||
if truncate {
|
|
||||||
query = "PRAGMA wal_checkpoint(TRUNCATE)"
|
|
||||||
} else {
|
|
||||||
query = "PRAGMA wal_checkpoint(RESTART)"
|
|
||||||
}
|
|
||||||
stmt, _, err := conn.PrepareTransient(query)
|
|
||||||
if err != nil {
|
|
||||||
s.log.WithError(err).Error("Failed to prepare checkpoint query.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer stmt.Finalize()
|
|
||||||
|
|
||||||
has, err := stmt.Step()
|
|
||||||
if err != nil {
|
|
||||||
s.log.WithError(err).Error("Failed to step through checkpoint query.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !has {
|
|
||||||
s.log.Error("Checkpoint query returned no rows.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
blocked := stmt.ColumnInt(0)
|
|
||||||
noWalPages := stmt.ColumnInt(1)
|
|
||||||
noReclaimedPages := stmt.ColumnInt(2)
|
|
||||||
if blocked == 1 {
|
|
||||||
s.log.Warn("Checkpoint query was blocked.")
|
|
||||||
}
|
|
||||||
s.log.Debugf("Checkpoint complete. %d pages written to WAL, %d pages written back to DB.", noWalPages, noReclaimedPages)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) setupDB() {
|
|
||||||
sqlitex.PoolCloseTimeout = time.Second * 10
|
|
||||||
s.log.Debugf("Using db %s", s.config.DbPath)
|
|
||||||
dbpool, err := sqlitex.Open(fmt.Sprintf("file:%s", s.config.DbPath), 0, 10)
|
|
||||||
if err != nil {
|
|
||||||
s.log.WithError(err).Fatal("Failed to open/create DB.")
|
|
||||||
}
|
|
||||||
s.dbpool = dbpool
|
|
||||||
s.checkpointNotice = make(chan struct{})
|
|
||||||
|
|
||||||
err = s.initDB(s.config.ReinitDB)
|
|
||||||
if err != nil {
|
|
||||||
s.log.WithError(err).Fatal("init DB transaction failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// aggressively checkpoint the database on idle times
|
|
||||||
go func() {
|
|
||||||
s.log.Debug("Starting manual restart checkpointing.")
|
|
||||||
defer s.log.Debug("Manual restart checkpointing stopped.")
|
|
||||||
delay := time.Minute * 15
|
|
||||||
var (
|
|
||||||
timer <-chan time.Time
|
|
||||||
ok bool
|
|
||||||
)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case _, ok = <-s.checkpointNotice:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timer = time.After(delay)
|
|
||||||
case <-timer:
|
|
||||||
func() {
|
|
||||||
conn := s.dbpool.Get(s.ctx)
|
|
||||||
defer s.dbpool.Put(conn)
|
|
||||||
s.checkpointDb(conn, false)
|
|
||||||
timer = nil
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) requestCheckpoint() {
|
|
||||||
go func() {
|
|
||||||
s.checkpointNotice <- struct{}{}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) setupTiles() {
|
func (s *Server) setupTiles() {
|
||||||
tsRootURL, err := url.Parse(URITileserverRoot)
|
tsRootURL, err := url.Parse(URITileserverRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -234,146 +124,6 @@ func (s *Server) setupTiles() {
|
|||||||
s.mapPackSize = info.Size()
|
s.mapPackSize = info.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) initDB(reinit bool) error {
|
|
||||||
s.log.Info("Initializing DB.")
|
|
||||||
conn := s.getDbConn()
|
|
||||||
defer s.dbpool.Put(conn)
|
|
||||||
|
|
||||||
defer s.requestCheckpoint()
|
|
||||||
|
|
||||||
if reinit {
|
|
||||||
s.log.Warn("Reinitializing DB.")
|
|
||||||
tables := []string{}
|
|
||||||
err := sqlitex.Exec(conn, "select name from sqlite_master where type = 'table'", func(stmt *sqlite.Stmt) error {
|
|
||||||
tables = append(tables, stmt.ColumnText(0))
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get table names: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range tables {
|
|
||||||
err = sqlitex.Exec(conn, "drop table "+table, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to drop tables: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = sqlitex.Exec(conn, "PRAGMA user_version = 0", nil)
|
|
||||||
if err != nil {
|
|
||||||
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
|
|
||||||
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.Debugf("Current db version: %d", version)
|
|
||||||
|
|
||||||
entries, err := sqlSchema.ReadDir("sql_schema")
|
|
||||||
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 {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getLatestVersion(v *semver.Version) (*models.AppVersionInfo, error) {
|
func (s *Server) getLatestVersion(v *semver.Version) (*models.AppVersionInfo, error) {
|
||||||
conn := s.getDbConn()
|
conn := s.getDbConn()
|
||||||
defer s.dbpool.Put(conn)
|
defer s.dbpool.Put(conn)
|
||||||
@ -398,20 +148,22 @@ func (s *Server) getAppVersions() ([]*models.AppVersionInfo, error) {
|
|||||||
defer s.dbpool.Put(conn)
|
defer s.dbpool.Put(conn)
|
||||||
|
|
||||||
versions := []*models.AppVersionInfo{}
|
versions := []*models.AppVersionInfo{}
|
||||||
err := sqlitex.Exec(conn, "select version, address from app_versions", func(stmt *sqlite.Stmt) error {
|
err := sqlitex.Execute(conn, "select version, address from app_versions", &sqlitex.ExecOptions{
|
||||||
verStr := stmt.ColumnText(0)
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
addr := stmt.ColumnText(1)
|
verStr := stmt.ColumnText(0)
|
||||||
|
addr := stmt.ColumnText(1)
|
||||||
|
|
||||||
ver, err := semver.NewVersion(verStr)
|
ver, err := semver.NewVersion(verStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse version: %w", err)
|
return fmt.Errorf("failed to parse version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versions = append(versions, &models.AppVersionInfo{
|
versions = append(versions, &models.AppVersionInfo{
|
||||||
Version: *ver,
|
Version: *ver,
|
||||||
Address: addr,
|
Address: addr,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to insert/retrieve user from db: %w", err)
|
return nil, fmt.Errorf("failed to insert/retrieve user from db: %w", err)
|
||||||
@ -424,7 +176,9 @@ func (s *Server) putAppVersion(versionInfo *models.AppVersionInfo) error {
|
|||||||
conn := s.getDbConn()
|
conn := s.getDbConn()
|
||||||
defer s.dbpool.Put(conn)
|
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)
|
err := sqlitex.Execute(conn, "insert into app_versions(version, address) values(?, ?) on conflict(version) do update set address = excluded.address", &sqlitex.ExecOptions{
|
||||||
|
Args: []interface{}{versionInfo.Version.String(), versionInfo.Address},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert app version into db: %w", err)
|
return fmt.Errorf("failed to insert app version into db: %w", err)
|
||||||
}
|
}
|
||||||
@ -437,19 +191,22 @@ func (s *Server) getAppVersion(version string) (*models.AppVersionInfo, error) {
|
|||||||
defer s.dbpool.Put(conn)
|
defer s.dbpool.Put(conn)
|
||||||
|
|
||||||
var v *models.AppVersionInfo
|
var v *models.AppVersionInfo
|
||||||
err := sqlitex.Exec(conn, "select version, address from app_versions where version = ?", func(stmt *sqlite.Stmt) error {
|
err := sqlitex.Execute(conn, "select version, address from app_versions where version = ?", &sqlitex.ExecOptions{
|
||||||
verStr := stmt.ColumnText(0)
|
Args: []interface{}{version},
|
||||||
ver, err := semver.NewVersion(verStr)
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
if err != nil {
|
verStr := stmt.ColumnText(0)
|
||||||
return fmt.Errorf("failed to parse version string %s from db: %w", verStr, err)
|
ver, err := semver.NewVersion(verStr)
|
||||||
}
|
if err != nil {
|
||||||
addr := stmt.ColumnText(1)
|
return fmt.Errorf("failed to parse version string %s from db: %w", verStr, err)
|
||||||
v = &models.AppVersionInfo{
|
}
|
||||||
Version: *ver,
|
addr := stmt.ColumnText(1)
|
||||||
Address: addr,
|
v = &models.AppVersionInfo{
|
||||||
}
|
Version: *ver,
|
||||||
return nil
|
Address: addr,
|
||||||
}, version)
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to retrieve app version %s from db: %w", version, err)
|
return nil, fmt.Errorf("failed to retrieve app version %s from db: %w", version, err)
|
||||||
}
|
}
|
||||||
@ -461,7 +218,9 @@ func (s *Server) deleteAppVersion(version string) (bool, error) {
|
|||||||
conn := s.getDbConn()
|
conn := s.getDbConn()
|
||||||
defer s.dbpool.Put(conn)
|
defer s.dbpool.Put(conn)
|
||||||
|
|
||||||
err := sqlitex.Exec(conn, "delete from app_versions where version = ?", nil, version)
|
err := sqlitex.Execute(conn, "delete from app_versions where version = ?", &sqlitex.ExecOptions{
|
||||||
|
Args: []interface{}{version},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to delete app version %s from db: %w", version, err)
|
return false, fmt.Errorf("failed to delete app version %s from db: %w", version, err)
|
||||||
}
|
}
|
||||||
@ -479,11 +238,14 @@ 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.Execute(conn, "select id from users where name = ?", &sqlitex.ExecOptions{
|
||||||
id = ptr(stmt.ColumnInt64(0))
|
Args: []interface{}{hc.Name},
|
||||||
return nil
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
}, hc.Name)
|
id = ptr(stmt.ColumnInt64(0))
|
||||||
if sqlite.ErrCode(err) != sqlite.SQLITE_OK {
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if sqlite.ErrCode(err) != sqlite.ResultOK {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if id == nil {
|
if id == nil {
|
||||||
@ -493,14 +255,17 @@ func (s *Server) handshake(hc models.HandshakeChallenge) (models.UserID, error)
|
|||||||
return 0, errs.ErrAttemptedSystemUser
|
return 0, errs.ErrAttemptedSystemUser
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
err = sqlitex.Exec(conn, "insert into users(name) values(?)", func(stmt *sqlite.Stmt) error {
|
err = sqlitex.Execute(conn, "insert into users(name) values(?)", &sqlitex.ExecOptions{
|
||||||
id = ptr(stmt.ColumnInt64(0))
|
Args: []interface{}{hc.Name},
|
||||||
return nil
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
}, hc.Name)
|
id = ptr(stmt.ColumnInt64(0))
|
||||||
if sqlite.ErrCode(err) == sqlite.SQLITE_CONSTRAINT_UNIQUE {
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if sqlite.ErrCode(err) == sqlite.ResultConstraintUnique {
|
||||||
return 0, errs.ErrUserAlreadyExists
|
return 0, errs.ErrUserAlreadyExists
|
||||||
}
|
}
|
||||||
if sqlite.ErrCode(err) != sqlite.SQLITE_OK {
|
if sqlite.ErrCode(err) != sqlite.ResultOK {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
id = ptr(conn.LastInsertRowID())
|
id = ptr(conn.LastInsertRowID())
|
||||||
@ -566,17 +331,19 @@ func (s *Server) getDataWithPhotos() (file *os.File, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data.PhotoMetadata = make(map[string]models.PhotoMetadata, 100)
|
data.PhotoMetadata = make(map[string]models.PhotoMetadata, 100)
|
||||||
err = sqlitex.Exec(conn, "select id, content_type, length(contents) from feature_photos", func(stmt *sqlite.Stmt) error {
|
err = sqlitex.Execute(conn, "select id, content_type, length(contents) from feature_photos", &sqlitex.ExecOptions{
|
||||||
id := models.FeaturePhotoID(stmt.ColumnInt64(0))
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
contentType := stmt.ColumnText(1)
|
id := models.FeaturePhotoID(stmt.ColumnInt64(0))
|
||||||
fileSize := stmt.ColumnInt64(2)
|
contentType := stmt.ColumnText(1)
|
||||||
data.PhotoMetadata[makePhotoFilename(id)] = models.PhotoMetadata{
|
fileSize := stmt.ColumnInt64(2)
|
||||||
ContentType: contentType,
|
data.PhotoMetadata[makePhotoFilename(id)] = models.PhotoMetadata{
|
||||||
Size: fileSize,
|
ContentType: contentType,
|
||||||
ID: id,
|
Size: fileSize,
|
||||||
ThumbnailFilename: makeThumbnailFilename(id),
|
ID: id,
|
||||||
}
|
ThumbnailFilename: makeThumbnailFilename(id),
|
||||||
return nil
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to collect photo metadata: %w", err)
|
return nil, fmt.Errorf("failed to collect photo metadata: %w", err)
|
||||||
@ -603,50 +370,52 @@ func (s *Server) getDataWithPhotos() (file *os.File, err error) {
|
|||||||
return nil, fmt.Errorf("failed to write data zip entry: %w", err)
|
return nil, fmt.Errorf("failed to write data zip entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = sqlitex.Exec(conn, "select id from feature_photos fp where exists (select 1 from features f where f.id = fp.feature_id)", func(stmt *sqlite.Stmt) error {
|
err = sqlitex.Execute(conn, "select id from feature_photos fp where exists (select 1 from features f where f.id = fp.feature_id)", &sqlitex.ExecOptions{
|
||||||
id := stmt.ColumnInt64(0)
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
|
id := stmt.ColumnInt64(0)
|
||||||
|
|
||||||
blob, err := conn.OpenBlob("", "feature_photos", "thumbnail_contents", id, false)
|
blob, err := conn.OpenBlob("", "feature_photos", "thumbnail_contents", id, false)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open photo ID %d thumbnail content blob: %w", id, err)
|
|
||||||
}
|
|
||||||
err = func() error {
|
|
||||||
defer blob.Close()
|
|
||||||
w, err := zw.Create(makeThumbnailFilename(models.FeaturePhotoID(id)))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create zip entry: %w", err)
|
return fmt.Errorf("failed to open photo ID %d thumbnail content blob: %w", id, err)
|
||||||
}
|
}
|
||||||
_, err = io.Copy(w, blob)
|
err = func() error {
|
||||||
|
defer blob.Close()
|
||||||
|
w, err := zw.Create(makeThumbnailFilename(models.FeaturePhotoID(id)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create zip entry: %w", err)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, blob)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write zip entry: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write zip entry: %w", err)
|
return fmt.Errorf("failed to write photo ID %d thumbnail: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blob, err = conn.OpenBlob("", "feature_photos", "contents", id, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open photo ID %d photo content blob: %w", id, err)
|
||||||
|
}
|
||||||
|
err = func() error {
|
||||||
|
defer blob.Close()
|
||||||
|
w, err := zw.Create(makePhotoFilename(models.FeaturePhotoID(id)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create zip entry: %w", err)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, blob)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write zip entry: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write photo ID %d photo: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}()
|
},
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write photo ID %d thumbnail: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blob, err = conn.OpenBlob("", "feature_photos", "contents", id, false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open photo ID %d photo content blob: %w", id, err)
|
|
||||||
}
|
|
||||||
err = func() error {
|
|
||||||
defer blob.Close()
|
|
||||||
w, err := zw.Create(makePhotoFilename(models.FeaturePhotoID(id)))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create zip entry: %w", err)
|
|
||||||
}
|
|
||||||
_, err = io.Copy(w, blob)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write zip entry: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write photo ID %d photo: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to collect photo files: %w", err)
|
return nil, fmt.Errorf("failed to collect photo files: %w", err)
|
||||||
@ -661,7 +430,7 @@ func (s *Server) update(data models.Update, photos map[string]models.Photo) erro
|
|||||||
defer s.dbpool.Put(conn)
|
defer s.dbpool.Put(conn)
|
||||||
|
|
||||||
return func() (err error) {
|
return func() (err error) {
|
||||||
defer sqlitex.Save(conn)(&err)
|
defer sqlitex.Transaction(conn)(&err)
|
||||||
|
|
||||||
var createdIDMapping map[models.FeatureID]models.FeatureID
|
var createdIDMapping map[models.FeatureID]models.FeatureID
|
||||||
if data.Create != nil {
|
if data.Create != nil {
|
||||||
@ -716,14 +485,10 @@ func (s *Server) update(data models.Update, photos map[string]models.Photo) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deleteExpiredFeatures(conn *sqlite.Conn) error {
|
func (s *Server) deleteExpiredFeatures(conn *sqlite.Conn) error {
|
||||||
stmt, err := conn.Prepare("delete from features where deadline < ?")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
|
||||||
}
|
|
||||||
defer stmt.Finalize()
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
err = sqlitex.Exec(conn, "delete from features where deadline < ?", func(stmt *sqlite.Stmt) error { return nil }, now)
|
err := sqlitex.Execute(conn, "delete from features where deadline < ?", &sqlitex.ExecOptions{
|
||||||
|
Args: []interface{}{now},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete expired features: %w", err)
|
return fmt.Errorf("failed to delete expired features: %w", err)
|
||||||
}
|
}
|
||||||
@ -734,16 +499,18 @@ func (s *Server) deleteExpiredFeatures(conn *sqlite.Conn) error {
|
|||||||
func (s *Server) getPeople(conn *sqlite.Conn) ([]models.User, error) {
|
func (s *Server) getPeople(conn *sqlite.Conn) ([]models.User, error) {
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
conn = s.getDbConn()
|
conn = s.getDbConn()
|
||||||
defer s.dbpool.Put(conn)
|
defer s.returnDbConn(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
users := make([]models.User, 0, 50)
|
users := make([]models.User, 0, 50)
|
||||||
err := sqlitex.Exec(conn, "select id, name from users", func(stmt *sqlite.Stmt) error {
|
err := sqlitex.Execute(conn, "select id, name from users", &sqlitex.ExecOptions{
|
||||||
users = append(users, models.User{
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
ID: models.UserID(stmt.ColumnInt(0)),
|
users = append(users, models.User{
|
||||||
Name: stmt.ColumnText(1),
|
ID: models.UserID(stmt.ColumnInt(0)),
|
||||||
})
|
Name: stmt.ColumnText(1),
|
||||||
return nil
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get users from db: %w", err)
|
return nil, fmt.Errorf("failed to get users from db: %w", err)
|
||||||
@ -758,56 +525,58 @@ func (s *Server) getFeatures(conn *sqlite.Conn) ([]models.Feature, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
features := make([]models.Feature, 0, 100)
|
features := make([]models.Feature, 0, 100)
|
||||||
err := sqlitex.Exec(conn, `select f.id, f.owner_id, f.name, f.deadline, f.properties, f.geom, '[' || coalesce(group_concat(p.id, ', '), '') || ']'
|
err := sqlitex.Execute(conn, `select f.id, f.owner_id, f.name, f.deadline, f.properties, f.geom, '[' || coalesce(group_concat(p.id, ', '), '') || ']'
|
||||||
from features f
|
from features f
|
||||||
left join feature_photos p on f.id = p.feature_id
|
left join feature_photos p on f.id = p.feature_id
|
||||||
group by f.id, f.owner_id, f.name, f.properties, f.geom`, func(stmt *sqlite.Stmt) error {
|
group by f.id, f.owner_id, f.name, f.properties, f.geom`, &sqlitex.ExecOptions{
|
||||||
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
|
|
||||||
id := stmt.ColumnInt64(0)
|
id := stmt.ColumnInt64(0)
|
||||||
|
|
||||||
ownerID := stmt.ColumnInt64(1)
|
ownerID := stmt.ColumnInt64(1)
|
||||||
|
|
||||||
name := stmt.ColumnText(2)
|
name := stmt.ColumnText(2)
|
||||||
|
|
||||||
var deadline *time.Time
|
var deadline *time.Time
|
||||||
if stmt.ColumnType(3) != sqlite.SQLITE_NULL {
|
if stmt.ColumnType(3) != sqlite.TypeNull {
|
||||||
dl := time.Unix(stmt.ColumnInt64(3), 0)
|
dl := time.Unix(stmt.ColumnInt64(3), 0)
|
||||||
deadline = &dl
|
deadline = &dl
|
||||||
}
|
}
|
||||||
|
|
||||||
propertiesRaw := stmt.ColumnText(4)
|
propertiesRaw := stmt.ColumnText(4)
|
||||||
var properties map[string]interface{}
|
var properties map[string]interface{}
|
||||||
err := json.Unmarshal([]byte(propertiesRaw), &properties)
|
err := json.Unmarshal([]byte(propertiesRaw), &properties)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse properties for feature id=%d: %w", id, err)
|
return fmt.Errorf("failed to parse properties for feature id=%d: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
geomRaw := stmt.ColumnText(5)
|
geomRaw := stmt.ColumnText(5)
|
||||||
var geom geojson.Geometry
|
var geom geojson.Geometry
|
||||||
err = json.Unmarshal([]byte(geomRaw), &geom)
|
err = json.Unmarshal([]byte(geomRaw), &geom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse geometry for feature id=%d: %w", id, err)
|
return fmt.Errorf("failed to parse geometry for feature id=%d: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
photosRaw := stmt.ColumnText(6)
|
photosRaw := stmt.ColumnText(6)
|
||||||
var photos []models.FeaturePhotoID
|
var photos []models.FeaturePhotoID
|
||||||
err = json.Unmarshal([]byte(photosRaw), &photos)
|
err = json.Unmarshal([]byte(photosRaw), &photos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse list of photo IDs: %w", err)
|
return fmt.Errorf("failed to parse list of photo IDs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
feature := models.Feature{
|
feature := models.Feature{
|
||||||
ID: models.FeatureID(id),
|
ID: models.FeatureID(id),
|
||||||
OwnerID: models.UserID(ownerID),
|
OwnerID: models.UserID(ownerID),
|
||||||
Name: name,
|
Name: name,
|
||||||
Deadline: deadline,
|
Deadline: deadline,
|
||||||
Properties: properties,
|
Properties: properties,
|
||||||
Geometry: geom,
|
Geometry: geom,
|
||||||
PhotoIDs: photos,
|
PhotoIDs: photos,
|
||||||
}
|
}
|
||||||
|
|
||||||
features = append(features, feature)
|
features = append(features, feature)
|
||||||
return nil
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get users from db: %w", err)
|
return nil, fmt.Errorf("failed to get users from db: %w", err)
|
||||||
@ -1101,16 +870,19 @@ func (s *Server) getPhoto(featureID models.FeatureID, photoID models.FeaturePhot
|
|||||||
var contentType *string = nil
|
var contentType *string = nil
|
||||||
var data []byte = nil
|
var data []byte = nil
|
||||||
found := false
|
found := false
|
||||||
err := sqlitex.Exec(conn, "select content_type, contents from feature_photos where id = ? and feature_id = ?", func(stmt *sqlite.Stmt) error {
|
err := sqlitex.Execute(conn, "select content_type, contents from feature_photos where id = ? and feature_id = ?", &sqlitex.ExecOptions{
|
||||||
if found {
|
Args: []interface{}{photoID, featureID},
|
||||||
return fmt.Errorf("multiple photos returned for feature id %d, photo id %d", featureID, photoID)
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
}
|
if found {
|
||||||
contentType = ptr(stmt.ColumnText(0))
|
return fmt.Errorf("multiple photos returned for feature id %d, photo id %d", featureID, photoID)
|
||||||
data = make([]byte, stmt.ColumnLen(1))
|
}
|
||||||
stmt.ColumnBytes(1, data)
|
contentType = ptr(stmt.ColumnText(0))
|
||||||
found = true
|
data = make([]byte, stmt.ColumnLen(1))
|
||||||
return nil
|
stmt.ColumnBytes(1, data)
|
||||||
}, photoID, featureID)
|
found = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("photo db query failed: %w", err)
|
return nil, "", fmt.Errorf("photo db query failed: %w", err)
|
||||||
}
|
}
|
||||||
@ -1159,16 +931,57 @@ func (s *Server) getProposals(conn *sqlite.Conn) ([]models.Proposal, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
proposals := make([]models.Proposal, 0, 100)
|
proposals := make([]models.Proposal, 0, 100)
|
||||||
err := sqlitex.Exec(conn, "select owner_id, description, how from proposals", func(stmt *sqlite.Stmt) error {
|
err := sqlitex.Execute(conn, "select owner_id, description, how from proposals", &sqlitex.ExecOptions{
|
||||||
proposals = append(proposals, models.Proposal{
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
OwnerID: models.UserID(stmt.ColumnInt(0)),
|
proposals = append(proposals, models.Proposal{
|
||||||
Description: stmt.ColumnText(1),
|
OwnerID: models.UserID(stmt.ColumnInt(0)),
|
||||||
How: stmt.ColumnText(2),
|
Description: stmt.ColumnText(1),
|
||||||
})
|
How: stmt.ColumnText(2),
|
||||||
return nil
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get proposals from db: %w", err)
|
return nil, fmt.Errorf("failed to get proposals from db: %w", err)
|
||||||
}
|
}
|
||||||
return proposals, nil
|
return proposals, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUsageInfo(conn *sqlite.Conn) ([]models.UserInfo, error) {
|
||||||
|
if conn == nil {
|
||||||
|
conn = s.getDbConn()
|
||||||
|
defer s.returnDbConn(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]models.UserInfo, 0, 50)
|
||||||
|
err := sqlitex.Execute(conn, "select id, name, app_version, last_seen_time, last_upload_time, last_download_time from users", &sqlitex.ExecOptions{
|
||||||
|
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||||
|
ver, _ := semver.NewVersion(stmt.ColumnText(2))
|
||||||
|
var lstp, lutp, ldtp *time.Time
|
||||||
|
if stmt.ColumnType(3) != sqlite.TypeNull {
|
||||||
|
lstp = ptr(time.Unix(stmt.ColumnInt64(3), 0))
|
||||||
|
}
|
||||||
|
if stmt.ColumnType(4) != sqlite.TypeNull {
|
||||||
|
lutp = ptr(time.Unix(stmt.ColumnInt64(3), 0))
|
||||||
|
}
|
||||||
|
if stmt.ColumnType(5) != sqlite.TypeNull {
|
||||||
|
ldtp = ptr(time.Unix(stmt.ColumnInt64(3), 0))
|
||||||
|
}
|
||||||
|
users = append(users, models.UserInfo{
|
||||||
|
User: models.User{
|
||||||
|
ID: models.UserID(stmt.ColumnInt(0)),
|
||||||
|
Name: stmt.ColumnText(1),
|
||||||
|
},
|
||||||
|
AppVersion: ver,
|
||||||
|
LastSeenTime: lstp,
|
||||||
|
LastUploadTime: lutp,
|
||||||
|
LastDownloadTime: ldtp,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get users from db: %w", err)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
@ -8,7 +8,7 @@ CREATE TABLE features (
|
|||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
owner_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
owner_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
deadline text,
|
deadline integer,
|
||||||
properties text NOT NULL,
|
properties text NOT NULL,
|
||||||
geom text NOT NULL
|
geom text NOT NULL
|
||||||
);
|
);
|
||||||
|
4
server/sql_schema/V4_user_info.sql
Normal file
4
server/sql_schema/V4_user_info.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN app_version text;
|
||||||
|
ALTER TABLE users ADD COLUMN last_seen_time integer;
|
||||||
|
ALTER TABLE users ADD COLUMN last_upload_time integer;
|
||||||
|
ALTER TABLE users ADD COLUMN last_download_time integer;
|
@ -17,6 +17,14 @@ func ptr[T any](x T) *T {
|
|||||||
return &x
|
return &x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Map[T any, U any](f func(T) U, x []T) []U {
|
||||||
|
res := make([]U, len(x))
|
||||||
|
for i, e := range x {
|
||||||
|
res[i] = f(e)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
var contentTypes map[string]struct{} = map[string]struct{}{"image/jpeg": {}, "image/png": {}}
|
var contentTypes map[string]struct{} = map[string]struct{}{"image/jpeg": {}, "image/png": {}}
|
||||||
|
|
||||||
func checkImageContentType(contentType string) bool {
|
func checkImageContentType(contentType string) bool {
|
||||||
|
Loading…
Reference in New Issue
Block a user