1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-22 17:10:10 +00:00
jfa-go/updater.go
Harvey Tindall 92332206f0
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.
2021-03-07 15:23:44 +00:00

488 lines
12 KiB
Go

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