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 @@
+
+
+
{{ .strings.updates }} ×
+
+
+ ()
+
+
+
+
+
+
{{ .strings.download }}
+
{{ .strings.update }}
+
+
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)
+ }
+}