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) + } +}