From 92332206f047a2c90af697ff4a43062dc108c63a Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 7 Mar 2021 15:23:44 +0000 Subject: [PATCH] add basic update functionality If enabled, jfa-go pings buildrone (hosted at builds.hrfee.pw) every 30 min for new updates. If there is one, it gets information (and if applicable, a binary) from the appropriate source (buildrone, github, or dockerhub) and displays it on the admin page. You can switch update channels between stable and unstable. For binary releases, updates are downloaded automatically and installed when the user presses update. Since this obviously introduces some "phone-home" functionality into jfa-go, I just want to say IPs are not and will not be logged by buildrone, although I may later introduce functionality to give a rough idea of the number of users (again, no IPs stored). The whole thing can also be turned off in settings. --- .drone.yml | 27 ++- .goreleaser.yml | 2 + Dockerfile | 2 +- Makefile | 12 +- api.go | 33 +++ config.go | 22 ++ config/config-base.json | 29 +++ css/base.css | 28 +++ embed/external.go | 2 + embed/internal.go | 2 + go.mod | 5 +- go.sum | 8 + html/admin.html | 15 ++ lang/admin/en-us.json | 10 +- main.go | 22 +- models.go | 5 + package-lock.json | 6 +- package.json | 2 +- router.go | 2 + scripts/version.py | 27 --- scripts/version.sh | 3 + ts/admin.ts | 6 +- ts/modules/settings.ts | 8 + ts/modules/update.ts | 124 ++++++++++ ts/typings/d.ts | 19 ++ updater.go | 487 ++++++++++++++++++++++++++++++++++++++++ 26 files changed, 858 insertions(+), 50 deletions(-) delete mode 100644 scripts/version.py create mode 100755 scripts/version.sh create mode 100644 ts/modules/update.ts create mode 100644 updater.go diff --git a/.drone.yml b/.drone.yml index 02ea20c..7c3e425 100644 --- a/.drone.yml +++ b/.drone.yml @@ -18,7 +18,12 @@ steps: - apt install build-essential python3-pip curl software-properties-common sed upx -y - (curl -sL https://deb.nodesource.com/setup_14.x | bash -) - apt install nodejs - - curl -sL https://git.io/goreleaser | bash + - curl -sL https://git.io/goreleaser > goreleaser + - chmod +x goreleaser + - ./scripts/version.sh ./goreleaser + - wget https://builds.hrfee.pw/upload.py + - pip3 install requests + - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag internal=true' trigger: event: - tag @@ -46,6 +51,9 @@ steps: command_timeout: 50m script: - /mnt/buildx/jfa-go/build.sh stable + - wget https://builds.hrfee.pw/upload.py + - pip3 install requests + - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-stable=true' trigger: event: - tag @@ -66,12 +74,12 @@ steps: - apt install build-essential python3-pip curl software-properties-common sed upx -y - (curl -sL https://deb.nodesource.com/setup_14.x | bash -) - apt install nodejs - - curl -sL https://git.io/goreleaser > goreleaser.sh - - chmod +x goreleaser.sh - - ./goreleaser.sh --snapshot --skip-publish --rm-dist + - curl -sL https://git.io/goreleaser > goreleaser + - chmod +x goreleaser + - ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist - wget https://builds.hrfee.pw/upload.py - pip3 install requests - - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go ./dist/*.tar.gz' + - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --upload ./dist/*.tar.gz --tag internal-git=true' environment: BUILDRONE_KEY: from_secret: BUILDRONE_KEY @@ -108,6 +116,9 @@ steps: command_timeout: 50m script: - /mnt/buildx/jfa-go/build.sh + - wget https://builds.hrfee.pw/upload.py + - pip3 install requests + - bash -c 'python3 upload.py https://builds.hrfee.pw hrfee jfa-go --tag docker-unstable=true' trigger: branch: - main @@ -132,9 +143,9 @@ steps: - apt install build-essential python3-pip curl software-properties-common sed upx -y - (curl -sL https://deb.nodesource.com/setup_14.x | bash -) - apt install nodejs - - curl -sL https://git.io/goreleaser > goreleaser.sh - - chmod +x goreleaser.sh - - ./goreleaser.sh --snapshot --skip-publish --rm-dist + - curl -sL https://git.io/goreleaser > goreleaser + - chmod +x goreleaser + - ./scripts/version.sh ./goreleaser --snapshot --skip-publish --rm-dist trigger: event: diff --git a/.goreleaser.yml b/.goreleaser.yml index d3fc23b..d6e0af5 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -30,6 +30,8 @@ builds: - dir: ./ env: - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{.Env.VERSION}} -X main.commit={{.ShortCommit}} -X main.updater=binary goos: - linux - windows diff --git a/Dockerfile b/Dockerfile index b9fbe73..b5aac25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ ENV GOARCH=$TARGETARCH COPY --from=support /opt/build /opt/build -RUN (cd /opt/build; make compile) +RUN (cd /opt/build; make compile UPDATER=docker) FROM golang:latest diff --git a/Makefile b/Makefile index 0090e9e..8ec3616 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,14 @@ VERSION ?= $(shell git describe --exact-match HEAD 2> /dev/null || echo vgit) VERSION := $(shell echo $(VERSION) | sed 's/v//g') COMMIT ?= $(shell git rev-parse --short HEAD || echo unknown) +UPDATER ?= off +BUILDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) +ifeq ($(UPDATER), on) + BUILDFLAGS := $(BUILDFLAGS) -X main.updater=binary +else ifneq ($(UPDATER), off) + BUILDFLAGS := $(BUILDFLAGS) -X main.updater=$(UPDATER) +endif + npm: $(info installing npm dependencies) npm install @@ -56,14 +64,14 @@ compile: $(GOBINARY) mod download $(info Building) mkdir -p build - cd build && CGO_ENABLED=0 $(GOBINARY) build -ldflags="-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o ./jfa-go ../*.go + cd build && CGO_ENABLED=0 $(GOBINARY) build -ldflags="-s -w $(BUILDFLAGS)" -o ./jfa-go ../*.go compile-debug: $(info Downloading deps) $(GOBINARY) mod download $(info Building) mkdir -p build - cd build && CGO_ENABLED=0 $(GOBINARY) build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o ./jfa-go ../*.go + cd build && CGO_ENABLED=0 $(GOBINARY) build -ldflags "$(BUILDFLAGS)" -o ./jfa-go ../*.go compress: upx --lzma build/jfa-go diff --git a/api.go b/api.go index 6f9e9b0..3627a28 100644 --- a/api.go +++ b/api.go @@ -1440,6 +1440,39 @@ func (app *appContext) SetEmailState(gc *gin.Context) { respondBool(200, true, gc) } +// @Summary Returns whether there's a new update, and extra info if there is. +// @Produce json +// @Success 200 {object} checkUpdateDTO +// @Router /config/update [get] +// @tags Configuration +func (app *appContext) CheckUpdate(gc *gin.Context) { + if !app.newUpdate { + app.update = Update{} + } + gc.JSON(200, checkUpdateDTO{New: app.newUpdate, Update: app.update}) +} + +// @Summary Apply an update. +// @Produce json +// @Success 200 {object} boolResponse +// @Success 400 {object} stringResponse +// @Success 500 {object} boolResponse +// @Router /config/update [post] +// @tags Configuration +func (app *appContext) ApplyUpdate(gc *gin.Context) { + if !app.update.CanUpdate { + respond(400, "Update is manual", gc) + return + } + err := app.update.update() + if err != nil { + app.err.Printf("Failed to apply update: %s", err) + respondBool(500, false, gc) + return + } + respondBool(200, true, gc) +} + // @Summary Returns the custom email (generating it if not set) and list of used variables in it. // @Produce json // @Success 200 {object} customEmailDTO diff --git a/config.go b/config.go index e7d9c31..feb33c4 100644 --- a/config.go +++ b/config.go @@ -84,6 +84,28 @@ func (app *appContext) loadConfig() error { emailEnabled = true } + app.MustSetValue("updates", "enabled", "true") + releaseChannel := app.config.Section("updates").Key("channel").String() + if app.config.Section("updates").Key("enabled").MustBool(false) { + v := version + if releaseChannel == "stable" { + if version == "git" { + v = "0.0.0" + } + } else if releaseChannel == "unstable" { + v = "git" + } + app.updater = newUpdater(baseURL, namespace, repo, v, commit, updater) + } + if releaseChannel == "" { + if version == "git" { + releaseChannel = "unstable" + } else { + releaseChannel = "stable" + } + app.MustSetValue("updates", "channel", releaseChannel) + } + app.storage.customEmails_path = app.config.Section("files").Key("custom_emails").String() app.storage.loadCustomEmails() diff --git a/config/config-base.json b/config/config-base.json index 4d39470..6e0c435 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -1,6 +1,35 @@ { "order": [], "sections": { + "updates": { + "order": [], + "meta": { + "name": "Updates", + "description": "Settings for update notifications and release channel." + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": true, + "requires_restart": true, + "type": "bool", + "value": true, + "description": "Enable/disable updating notifications and downloading/applying updates." + }, + "channel": { + "name": "Release Channel", + "required": true, + "requires_restart": false, + "type": "select", + "options": [ + ["stable", "Stable"], + ["unstable", "Unstable"] + ], + "value": "", + "description": "Release channel for updates." + } + } + }, "jellyfin": { "order": [], "meta": { diff --git a/css/base.css b/css/base.css index 7a347da..5db409c 100644 --- a/css/base.css +++ b/css/base.css @@ -417,3 +417,31 @@ pre { white-space: -o-pre-wrap; /* Opera 7 */ word-wrap: break-word; /* Internet Explorer 5.5+ */ } + +.circle { + height: 0.5rem; + width: 0.5rem; + border-radius: 50%; +} + +.circle.\~urge { + background-color: var(--color-urge-200); +} + +.markdown-box { + max-height: 20rem; + display: block; + overflow-y: scroll; +} + +a:link { + color: var(--color-urge-200); +} + +a:visited { + color: var(--color-urge-100); +} + +a:hover, a:active { + color: var(--color-urge-200); +} diff --git a/embed/external.go b/embed/external.go index f599a4e..060ad16 100644 --- a/embed/external.go +++ b/embed/external.go @@ -8,6 +8,8 @@ import ( "strings" ) +const binaryType = "external" + var localFS fs.FS var langFS fs.FS diff --git a/embed/internal.go b/embed/internal.go index 365f515..1863cf6 100644 --- a/embed/internal.go +++ b/embed/internal.go @@ -6,6 +6,8 @@ import ( "log" ) +const binaryType = "internal" + //go:embed data data/html data/web data/web/css data/web/js var loFS embed.FS diff --git a/go.mod b/go.mod index 048cd1d..43176f9 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ replace github.com/hrfee/jfa-go/common => ./common replace github.com/hrfee/jfa-go/ombi => ./ombi require ( + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/evanw/esbuild v0.8.50 // indirect github.com/fatih/color v1.10.0 @@ -34,6 +35,7 @@ require ( github.com/mailgun/mailgun-go/v4 v4.3.0 github.com/mailru/easyjson v0.7.7 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/smartystreets/goconvey v1.6.4 // indirect github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 github.com/swaggo/gin-swagger v1.3.0 @@ -41,7 +43,8 @@ require ( github.com/ugorji/go v1.2.0 // indirect github.com/writeas/go-strip-markdown v2.0.1+incompatible golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect - golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43 // indirect + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect + golang.org/x/sys v0.0.0-20210303074136-134d130e1a04 // indirect golang.org/x/tools v0.1.0 // indirect google.golang.org/protobuf v1.25.0 // indirect gopkg.in/ini.v1 v1.62.0 diff --git a/go.sum b/go.sum index 91e5f2f..ffe1e48 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -188,6 +190,8 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCb github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= @@ -266,6 +270,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -293,6 +299,8 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbq golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43 h1:SgQ6LNaYJU0JIuEHv9+s6EbhSCwYeAf5Yvj6lpYlqAE= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04 h1:cEhElsAv9LUt9ZUUocxzWe05oFLVd+AA2nstydTeI8g= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/html/admin.html b/html/admin.html index f31505c..5fa6bda 100644 --- a/html/admin.html +++ b/html/admin.html @@ -254,6 +254,21 @@ +
diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index f08682c..164ab64 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -24,6 +24,9 @@ "enabled": "Enabled", "disabled": "Disabled", "admin": "Admin", + "updates": "Updates", + "update": "Update", + "download": "Download", "lastActiveTime": "Last Active", "from": "From", "user": "User", @@ -93,6 +96,7 @@ "saveEmail": "Email saved.", "sentAnnouncement": "Announcement sent.", "setOmbiDefaults": "Stored ombi defaults.", + "updateApplied": "Update applied, please restart.", "errorConnection": "Couldn't connect to jfa-go.", "error401Unauthorized": "Unauthorized. Try refreshing the page.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", @@ -115,7 +119,11 @@ "errorFailureCheckLogs": "Failed (check console/logs)", "errorPartialFailureCheckLogs": "Partial failure (check console/logs)", "errorUserCreated": "Failed to create user {n}.", - "errorSendWelcomeEmail": "Failed to send welcome email (check console/logs)" + "errorSendWelcomeEmail": "Failed to send welcome email (check console/logs)", + "errorApplyUpdate": "Failed to apply update, try manually.", + "errorCheckUpdate": "Failed to check for update.", + "updateAvailable": "A new update is available, check settings.", + "noUpdatesAvailable": "No new updates available." }, "quantityStrings": { "modifySettingsFor": { diff --git a/main.go b/main.go index acdc5f9..95bc52f 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,14 @@ var ( commit string ) +var temp = func() string { + temp := "/tmp" + if PLATFORM == "windows" { + temp = os.Getenv("TEMP") + } + return temp +}() + var serverTypes = map[string]string{ "jellyfin": "Jellyfin", "emby": "Emby (experimental)", @@ -90,6 +98,10 @@ type appContext struct { version string quit chan os.Signal URLBase string + updater *Updater + newUpdate bool // Whether whatever's in update is new. + tag Tag + update Update } func generateSecret(length int) (string, error) { @@ -521,6 +533,10 @@ func start(asDaemon, firstCall bool) { if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer { go app.StartPWR() } + + if app.config.Section("updates").Key("enabled").MustBool(false) { + go app.checkForUpdates() + } } else { debugMode = false address = "0.0.0.0:8056" @@ -636,11 +652,7 @@ func printVersion() { func main() { printVersion() - folder := "/tmp" - if PLATFORM == "windows" { - folder = os.Getenv("TEMP") - } - SOCK = filepath.Join(folder, SOCK) + SOCK = filepath.Join(temp, SOCK) fmt.Println("Socket:", SOCK) if flagPassed("test") { TEST = true diff --git a/models.go b/models.go index 8797176..5b63f6c 100644 --- a/models.go +++ b/models.go @@ -214,3 +214,8 @@ type extendExpiryDTO struct { Hours int `json:"hours" example:"2"` // Number of hours to add. Minutes int `json:"minutes" example:"3"` // Number of minutes to add. } + +type checkUpdateDTO struct { + New bool `json:"new"` // Whether or not there's a new update. + Update Update `json:"update"` +} diff --git a/package-lock.json b/package-lock.json index 8941dc8..dabad04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -236,9 +236,9 @@ "integrity": "sha1-vfpzUplmTfr9NFKe1PhSKidf6lY=" }, "esbuild": { - "version": "0.8.53", - "resolved": "https://registry.npm.taobao.org/esbuild/download/esbuild-0.8.53.tgz", - "integrity": "sha1-tAi7DKGynasT2Lv31Z9Zr+Z3boY=" + "version": "0.8.56", + "resolved": "https://registry.npm.taobao.org/esbuild/download/esbuild-0.8.56.tgz", + "integrity": "sha1-nHw9bmFNtzZ6+jSK2wqyh8KWc14=" }, "escalade": { "version": "3.1.1", diff --git a/package.json b/package.json index 4256483..646bfd4 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dependencies": { "@ts-stack/markdown": "^1.3.0", "a17t": "^0.4.0", - "esbuild": "^0.8.53", + "esbuild": "^0.8.56", "lodash": "^4.17.19", "mjml": "^4.8.0", "remixicon": "^2.5.0", diff --git a/router.go b/router.go index 22a8892..5e2c524 100644 --- a/router.go +++ b/router.go @@ -140,6 +140,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { // api.POST(p + "/setDefaults", app.SetDefaults) api.POST(p+"/users/settings", app.ApplySettings) api.POST(p+"/users/announce", app.Announce) + api.GET(p+"/config/update", app.CheckUpdate) + api.POST(p+"/config/update", app.ApplyUpdate) api.GET(p+"/config/emails", app.GetEmails) api.GET(p+"/config/emails/:id", app.GetEmail) api.POST(p+"/config/emails/:id", app.SetEmail) diff --git a/scripts/version.py b/scripts/version.py deleted file mode 100644 index 3e9b9ed..0000000 --- a/scripts/version.py +++ /dev/null @@ -1,27 +0,0 @@ -import subprocess -import sys - -try: - version = sys.argv[1].replace('v', '') -except IndexError: - version = "git" - -if version == "auto": - try: - version = subprocess.check_output("git describe --exact-match HEAD".split()).decode("utf-8").rstrip().replace('v', '') - except subprocess.CalledProcessError as e: - if e.returncode == 128: - version = "git" - -commit = subprocess.check_output("git rev-parse --short HEAD".split()).decode("utf-8").rstrip() - -file = f'package main; const VERSION = "{version}"; const COMMIT = "{commit}";' - -try: - writeto = sys.argv[2] -except IndexError: - writeto = "version.go" - -with open(writeto, 'w') as f: - f.write(file) - diff --git a/scripts/version.sh b/scripts/version.sh new file mode 100755 index 0000000..890320e --- /dev/null +++ b/scripts/version.sh @@ -0,0 +1,3 @@ +#!/bin/bash +VERSION=$(git describe --exact-match HEAD 2> /dev/null || echo 'vgit') +VERSION="$(echo $VERSION | sed 's/v//g')" $@ diff --git a/ts/admin.ts b/ts/admin.ts index 51756ef..a7516bb 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -7,6 +7,7 @@ import { accountsList } from "./modules/accounts.js"; import { settingsList } from "./modules/settings.js"; import { ProfileEditor } from "./modules/profiles.js"; import { _get, _post, notificationBox, whichAnimationEvent, toggleLoader } from "./modules/common.js"; +import { Updater } from "./modules/update.js"; loadTheme(); (document.getElementById('button-theme') as HTMLSpanElement).onclick = toggleTheme; @@ -59,9 +60,12 @@ window.availableProfiles = window.availableProfiles || []; window.modals.customizeEmails = new Modal(document.getElementById("modal-customize")); window.modals.extendExpiry = new Modal(document.getElementById("modal-extend-expiry")); + + window.modals.updateInfo = new Modal(document.getElementById("modal-update")); })(); var inviteCreator = new createInvite(); + var accounts = new accountsList(); window.invites = new inviteList(); @@ -154,6 +158,7 @@ function login(username: string, password: string, run?: (state?: number) => voi } else { const data = this.response; window.token = data["token"]; + window.updater = new Updater(); // mmm, a race condition window.modals.login.close(); setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000); const currentTab = window.tabs.current; @@ -198,4 +203,3 @@ login("", ""); return false; } }); - diff --git a/ts/modules/settings.ts b/ts/modules/settings.ts index 8eee8a0..64516f5 100644 --- a/ts/modules/settings.ts +++ b/ts/modules/settings.ts @@ -598,6 +598,14 @@ export class settingsList { `; (editButton.querySelector("span.button") as HTMLSpanElement).onclick = this._emailEditor.showList; this.addSection(name, settings.sections[name], editButton); + } else if (name == "updates") { + const icon = document.createElement("span") as HTMLSpanElement; + if (window.updater.updateAvailable) { + icon.classList.add("button", "~urge"); + icon.innerHTML = ``; + icon.onclick = () => window.updater.checkForUpdates(window.modals.updateInfo.show); + } + this.addSection(name, settings.sections[name], icon); } else { this.addSection(name, settings.sections[name]); } diff --git a/ts/modules/update.ts b/ts/modules/update.ts new file mode 100644 index 0000000..36d51bc --- /dev/null +++ b/ts/modules/update.ts @@ -0,0 +1,124 @@ +import { _get, _post, toggleLoader } from "../modules/common.js"; +import { Marked, Renderer } from "@ts-stack/markdown"; + +interface updateDTO { + new: boolean; + update: Update; +} + +export class Updater implements updater { + private _update: Update; + private _date: Date; + updateAvailable = false; + + checkForUpdates = (run?: (req: XMLHttpRequest) => void) => _get("/config/update", null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + if (req.status != 200) { + window.notifications.customError("errorCheckUpdate", window.lang.notif("errorCheckUpdate")); + return + } + let resp = req.response as updateDTO; + if (resp.new) { + this.update = resp.update; + if (run) { run(req); } + // } else { + // window.notifications.customPositive("noUpdatesAvailable", "", window.lang.notif("noUpdatesAvailable")); + } + } + }); + get date(): number { return Math.floor(this._date.getTime() / 1000); } + set date(unix: number) { + this._date = new Date(unix * 1000); + document.getElementById("update-date").textContent = this._date.toDateString() + " " + this._date.toLocaleTimeString(); + } + + get description(): string { return this._update.description; } + set description(description: string) { + this._update.description = description; + const el = document.getElementById("update-description") as HTMLParagraphElement; + el.textContent = description; + if (this.version == "git") { + el.classList.add("monospace"); + } else { + el.classList.remove("monospace"); + } + } + + get changelog(): string { return this._update.changelog; } + set changelog(changelog: string) { + this._update.changelog = changelog; + + document.getElementById("update-changelog").innerHTML = Marked.parse(changelog); + } + + get version(): string { return this._update.version; } + set version(version: string) { + this._update.version = version; + document.getElementById("update-version").textContent = version; + } + + get commit(): string { return this._update.commit; } + set commit(commit: string) { + this._update.commit = commit; + document.getElementById("update-commit").textContent = commit.slice(0, 7); + } + + get link(): string { return this._update.link; } + set link(link: string) { + this._update.link = link; + (document.getElementById("update-version") as HTMLAnchorElement).href = link; + } + + get download_link(): string { return this._update.download_link; } + set download_link(link: string) { this._update.download_link = link; } + + get can_update(): boolean { return this._update.can_update; } + set can_update(can: boolean) { + this._update.can_update = can; + const download = document.getElementById("update-download") as HTMLSpanElement; + const update = document.getElementById("update-update") as HTMLSpanElement; + if (can) { + download.classList.add("unfocused"); + update.classList.remove("unfocused"); + } else { + download.onclick = () => window.open(this._update.download_link || this._update.link); + download.classList.remove("unfocused"); + update.classList.add("unfocused"); + } + } + + get update(): Update { return this._update; } + set update(update: Update) { + this._update = update; + this.version = update.version; + this.commit = update.commit; + this.date = update.date; + this.description = update.description; + this.changelog = update.changelog; + this.link = update.link; + this.download_link = update.download_link; + this.can_update = update.can_update; + } + + constructor() { + const update = document.getElementById("update-update") as HTMLSpanElement; + update.onclick = () => { + toggleLoader(update); + _post("/config/update", null, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + toggleLoader(update); + if (req.status != 200) { + window.notifications.customError("applyUpdateError", window.lang.notif("errorApplyUpdate")); + } else { + window.notifications.customSuccess("applyUpdate", window.lang.notif("updateApplied")); + } + window.modals.updateInfo.close(); + } + }); + }; + this.checkForUpdates(() => { + this.updateAvailable = true; + window.notifications.customPositive("updateAvailable", "", window.lang.notif("updateAvailable")); + }); + } +} diff --git a/ts/typings/d.ts b/ts/typings/d.ts index e39d502..23de5a1 100644 --- a/ts/typings/d.ts +++ b/ts/typings/d.ts @@ -30,6 +30,24 @@ declare interface Window { language: string; lang: Lang; langFile: {}; + updater: updater; +} + +declare interface Update { + version: string; + commit: string; + date: number; + description: string; + changelog: string; + link: string; + download_link?: string; + can_update: boolean; +} + +declare interface updater extends Update { + checkForUpdates: (run?: (req: XMLHttpRequest) => void) => void; + updateAvailable: boolean; + update: Update; } declare interface Lang { @@ -78,6 +96,7 @@ declare interface Modals { editor: Modal; customizeEmails: Modal; extendExpiry: Modal; + updateInfo: Modal; } interface Invite { diff --git a/updater.go b/updater.go new file mode 100644 index 0000000..2dd33bc --- /dev/null +++ b/updater.go @@ -0,0 +1,487 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/hrfee/jfa-go/common" +) + +const ( + baseURL = "https://builds.hrfee.pw" + namespace = "hrfee" + repo = "jfa-go" +) + +type GHRelease struct { + HTMLURL string `json:"html_url"` + ID int `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + Assets []GHAsset `json:"assets"` + Body string `json:"body"` +} + +type GHAsset struct { + Name string `json:"name"` + State string `json:"state"` + UpdatedAt time.Time `json:"updated_at"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +type UnixTime struct { + time.Time +} + +func (t *UnixTime) UnmarshalJSON(b []byte) (err error) { + unix, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSuffix(string(b), "\""), "\""), 10, 64) + if err != nil { + return + } + t.Time = time.Unix(unix, 0) + return +} + +func (t UnixTime) MarshalJSON() ([]byte, error) { + if t.Time == (time.Time{}) { + return []byte("\"\""), nil + } + return []byte("\"" + strconv.FormatInt(t.Time.Unix(), 10) + "\""), nil +} + +var updater string + +type BuildType int + +const ( + off BuildType = iota + internal // Internal assets through go:embed, no data/. + external // External assets in data/, accesses through app.localFS. + docker // Only notify of new updates, no self-updating. +) + +type ApplyUpdate func() error + +type Update struct { + Version string `json:"version"` // vX.X.X or git + Commit string `json:"commit"` + ReleaseDate int64 `json:"date"` // unix time + Description string `json:"description"` // Commit Name/Release title. + Changelog string `json:"changelog"` // Changelog, if applicable + Link string `json:"link"` // Link to commit/release page, + DownloadLink string `json:"download_link"` // Optional link to download page. + CanUpdate bool `json:"can_update"` // Whether or not update can be done automatically. + update ApplyUpdate `json:"-"` // Function to apply update if possible. +} + +type Tag struct { + Ready bool `json:"ready"` // Whether or not build on this tag has completed. + Version string `json:"version,omitempty"` // Version/Commit + ReleaseDate UnixTime `json:"date"` +} + +var goos = map[string]string{ + "darwin": "macOS", + "linux": "Linux", + "windows": "Windows", +} + +var goarch = map[string]string{ + "amd64": "x86_64", + "arm64": "arm64", + "arm": "armv6", +} + +// func newDockerBuild() Update { +// var tag string +// if version == "git" { +// tag = "docker-unstable" +// } else { +// tag = "docker-latest" +// } +// } +type Updater struct { + version, commit, tag, url, namespace, name string + stable bool + buildType BuildType + httpClient *http.Client + timeoutHandler common.TimeoutHandler + binary string +} + +func newUpdater(buildroneURL, namespace, repo, version, commit, buildType string) *Updater { + bType := off + fmt.Println("BT", buildType) + tag := "" + switch buildType { + case "binary": + if binaryType == "internal" { + bType = internal + tag = "internal" + } else { + bType = external + tag = "external" + } + case "docker": + bType = docker + if version == "git" { + tag = "docker-unstable" + } else { + tag = "docker-latest" + } + default: + bType = off + } + if commit == "unknown" { + bType = off + } + if version == "git" && bType != docker { + tag += "-git" + } + binary := "jfa-go" + if runtime.GOOS == "windows" { + binary += ".exe" + } + return &Updater{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + timeoutHandler: common.NewTimeoutHandler("updater", buildroneURL, true), + version: version, + commit: commit, + buildType: bType, + tag: tag, + url: buildroneURL, + namespace: namespace, + name: repo, + binary: binary, + } +} + +type BuildDTO struct { + ID int64 // `json:"id"` + Name string // `json:"name"` + Date time.Time // `json:"date"` + Link string // `json:"link"` + Message string + Branch string // `json:"branch"` + Tags map[string]Tag +} + +func (ud *Updater) GetTag() (Tag, int, error) { + if ud.buildType == off { + return Tag{}, -1, nil + } + url := fmt.Sprintf("%s/repo/%s/%s/tag/latest/%s", ud.url, ud.namespace, ud.name, ud.tag) + req, _ := http.NewRequest("GET", url, nil) + resp, err := ud.httpClient.Do(req) + defer ud.timeoutHandler() + if err != nil || resp.StatusCode != 200 { + return Tag{}, resp.StatusCode, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return Tag{}, -1, err + } + + var tag Tag + err = json.Unmarshal(body, &tag) + return tag, resp.StatusCode, err +} + +func (t *Tag) IsNew() bool { + return t.Version == version +} + +func (ud *Updater) getRelease() (release GHRelease, status int, err error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", ud.namespace, ud.name) + req, _ := http.NewRequest("GET", url, nil) + resp, err := ud.httpClient.Do(req) + status = resp.StatusCode + defer ud.timeoutHandler() + if err != nil || resp.StatusCode != 200 { + return + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return + } + err = json.Unmarshal(body, &release) + return +} + +func (ud *Updater) GetUpdate(tag Tag) (update Update, status int, err error) { + switch ud.buildType { + case internal: + if ud.tag == "internal-git" { + update, status, err = ud.getUpdateInternalGit(tag) + } else if ud.tag == "internal" { + update, status, err = ud.getUpdateInternal(tag) + } + case external, docker: + if strings.Contains(ud.tag, "git") || ud.tag == "docker-unstable" { + update, status, err = ud.getCommitGit(tag) + } else { + var release GHRelease + release, status, err = ud.getRelease() + if err != nil { + return + } + update = Update{ + Changelog: release.Body, + Description: release.Name, + Version: release.TagName, + Commit: tag.Version, + Link: release.HTMLURL, + ReleaseDate: release.PublishedAt.Unix(), + } + } + if ud.buildType == docker { + update.DownloadLink = fmt.Sprintf("https://hub.docker.com/r/%s/%s/tags", ud.namespace, ud.name) + } + } + return +} + +func (ud *Updater) getUpdateInternal(tag Tag) (update Update, status int, err error) { + release, status, err := ud.getRelease() + update = Update{ + Changelog: release.Body, + Description: release.Name, + Version: release.TagName, + Commit: tag.Version, + Link: release.HTMLURL, + ReleaseDate: release.PublishedAt.Unix(), + } + if err != nil || status != 200 { + return + } + updateFunc, status, err := ud.downloadInternal(&release.Assets, tag) + if err == nil && status == 200 { + update.CanUpdate = true + update.update = updateFunc + } + return +} + +func (ud *Updater) getCommitGit(tag Tag) (update Update, status int, err error) { + url := fmt.Sprintf("%s/repo/%s/%s/build/%s", ud.url, ud.namespace, ud.name, tag.Version) + req, _ := http.NewRequest("GET", url, nil) + resp, err := ud.httpClient.Do(req) + status = resp.StatusCode + defer ud.timeoutHandler() + if err != nil || resp.StatusCode != 200 { + return + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return + } + var build BuildDTO + err = json.Unmarshal(body, &build) + if err != nil { + return + } + update = Update{ + Description: build.Name, + Version: "git", + Commit: tag.Version, + Link: build.Link, + ReleaseDate: tag.ReleaseDate.Unix(), + } + return +} + +func (ud *Updater) getUpdateInternalGit(tag Tag) (update Update, status int, err error) { + update, status, err = ud.getCommitGit(tag) + if err != nil || status != 200 { + return + } + updateFunc, status, err := ud.downloadInternalGit() + if err == nil && status == 200 { + update.CanUpdate = true + update.update = updateFunc + } + return +} + +func getBuildName() string { + operatingSystem, ok := goos[runtime.GOOS] + if !ok { + for _, v := range goos { + if strings.Contains(v, runtime.GOOS) { + operatingSystem = v + break + } + } + } + if operatingSystem == "" { + return "" + } + arch, ok := goarch[runtime.GOARCH] + if !ok { + for _, v := range goarch { + if strings.Contains(v, runtime.GOARCH) { + arch = v + break + } + } + } + if arch == "" { + return "" + } + return operatingSystem + "_" + arch +} + +func (ud *Updater) downloadInternal(assets *[]GHAsset, tag Tag) (applyUpdate ApplyUpdate, status int, err error) { + return ud.pullInternal(ud.getInternalURL(assets, tag)) +} + +func (ud *Updater) downloadInternalGit() (applyUpdate ApplyUpdate, status int, err error) { + return ud.pullInternal(ud.getInternalGitURL()) +} + +func (ud *Updater) getInternalURL(assets *[]GHAsset, tag Tag) string { + buildName := getBuildName() + if buildName == "" { + return "" + } + url := "" + for _, asset := range *assets { + if strings.Contains(asset.Name, buildName) { + url = asset.BrowserDownloadURL + break + } + } + return url +} + +func (ud *Updater) getInternalGitURL() string { + buildName := getBuildName() + if buildName == "" { + return "" + } + return fmt.Sprintf("%s/repo/%s/%s/latest/file/%s", ud.url, ud.namespace, ud.name, buildName) +} + +func (ud *Updater) pullInternal(url string) (applyUpdate ApplyUpdate, status int, err error) { + if url == "" { + return + } + req, _ := http.NewRequest("GET", url, nil) + resp, err := ud.httpClient.Do(req) + status = resp.StatusCode + if err != nil || resp.StatusCode != 200 { + return + } + gz, err := gzip.NewReader(resp.Body) + if err != nil { + status = -1 + return + } + tarReader := tar.NewReader(gz) + var header *tar.Header + for { + header, err = tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + status = -1 + return + } + switch header.Typeflag { + case tar.TypeReg: + // Search only for file named ud.binary + if header.Name == ud.binary { + applyUpdate = func() error { + defer gz.Close() + defer resp.Body.Close() + file, err := os.Executable() + if err != nil { + return err + } + path, err := filepath.EvalSymlinks(file) + if err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + mode := info.Mode() + f, err := os.OpenFile(path+"_", os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, tarReader) + if err != nil { + return err + } + return os.Rename(path+"_", path) + } + return + } + } + } + err = errors.New("Couldn't find file: " + ud.binary) + return +} + +// func newInternalBuild() Update { +// tag := "internal" + +// func update(path string) err { +// if +// fp, err := os.Executable() +// if err != nil { +// return err +// } +// fullPath, err := filepath.EvalSymlinks(fp) +// if err != nil { +// return err +// } +// newBinary, +// } +func (app *appContext) checkForUpdates() { + for { + go func() { + tag, status, err := app.updater.GetTag() + if status != 200 || err != nil { + if strings.Contains(err.Error(), "strconv.ParseInt") { + app.err.Println("No new updates available.") + } else { + app.err.Printf("Failed to get latest tag (%d): %v", status, err) + } + return + } + if tag != app.tag && tag.IsNew() { + app.info.Println("Update found") + update, status, err := app.updater.GetUpdate(tag) + if status != 200 || err != nil { + app.err.Printf("Failed to get update (%d): %v", status, err) + return + } + app.tag = tag + app.update = update + app.newUpdate = true + } + }() + time.Sleep(30 * time.Minute) + } +}