Skip to content

Commit

Permalink
Add support for http based repositories (#42)
Browse files Browse the repository at this point in the history
* Add support for http based repositories

* Add to the document tree

* Add status code checking, and fix documentation error.

* Fix copy paste error

* Change release ID to ID.

* Fix failing test on MacOS

* Use http test server instead, didn't know of it til now.

* Cleanup readme

* Move to assert.

* Correct a few more asserts.
  • Loading branch information
grmrgecko authored Oct 14, 2024
1 parent 197320e commit e15540b
Show file tree
Hide file tree
Showing 6 changed files with 573 additions and 0 deletions.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Self-Update library for Github, Gitea and Gitlab hosted applications in Go
* [Other providers than Github](#other-providers-than-github)
* [GitLab](#gitlab)
* [Example:](#example-1)
* [Http Based Repository](#http-based-repository)
* [Example:](#example-2)
* [Copyright](#copyright)

<!--te-->
Expand Down Expand Up @@ -384,6 +386,52 @@ func update() {
}
```

# Http Based Repository

Support for http based repositories landed in version 1.4.0.

The HttpSource is designed to work with repositories built using [goreleaser-http-repo-builder](https://github.com/GRMrGecko/goreleaser-http-repo-builder?tab=readme-ov-file). This provides a simple way to add self-update support to software that is not open source, allowing you to host your own updates. It requires that you still use the owner/project url style, and you can set custom headers to be used with requests to authenticate.

## Example:

If your repository is at example.com/repo/project, then you'd use the following example.

```go
func update() {
source, err := selfupdate.NewHttpSource(selfupdate.HttpConfig{
BaseURL: "https://example.com/",
})
if err != nil {
log.Fatal(err)
}
updater, err := selfupdate.NewUpdater(selfupdate.Config{
Source: source,
Validator: &selfupdate.ChecksumValidator{UniqueFilename: "checksums.txt"}, // checksum from goreleaser
})
if err != nil {
log.Fatal(err)
}
release, found, err := updater.DetectLatest(context.Background(), selfupdate.NewRepositorySlug("repo", "project"))
if err != nil {
log.Fatal(err)
}
if !found {
log.Print("Release not found")
return
}
fmt.Printf("found release %s\n", release.Version())

exe, err := selfupdate.ExecutablePath()
if err != nil {
return errors.New("could not locate executable path")
}
err = updater.UpdateTo(context.Background(), release, exe)
if err != nil {
log.Fatal(err)
}
}
```

# Copyright

This work is heavily based on:
Expand Down
104 changes: 104 additions & 0 deletions http_release.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2024 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

package selfupdate

import (
"time"
)

type HttpAsset struct {
ID int64 `yaml:"id"`
Name string `yaml:"name"`
Size int `yaml:"size"`
URL string `yaml:"url"`
}

func (a *HttpAsset) GetID() int64 {
return a.ID
}

func (a *HttpAsset) GetName() string {
return a.Name
}

func (a *HttpAsset) GetSize() int {
return a.Size
}

func (a *HttpAsset) GetBrowserDownloadURL() string {
return a.URL
}

var _ SourceAsset = &HttpAsset{}

type HttpRelease struct {
ID int64 `yaml:"id"`
Name string `yaml:"name"`
TagName string `yaml:"tag_name"`
URL string `yaml:"url"`
Draft bool `yaml:"draft"`
Prerelease bool `yaml:"prerelease"`
PublishedAt time.Time `yaml:"published_at"`
ReleaseNotes string `yaml:"release_notes"`
Assets []*HttpAsset `yaml:"assets"`
}

func (r *HttpRelease) GetID() int64 {
return r.ID
}

func (r *HttpRelease) GetTagName() string {
return r.TagName
}

func (r *HttpRelease) GetDraft() bool {
return r.Draft
}

func (r *HttpRelease) GetPrerelease() bool {
return r.Prerelease
}

func (r *HttpRelease) GetPublishedAt() time.Time {
return r.PublishedAt
}

func (r *HttpRelease) GetReleaseNotes() string {
return r.ReleaseNotes
}

func (r *HttpRelease) GetName() string {
return r.Name
}

func (r *HttpRelease) GetURL() string {
return r.URL
}

func (r *HttpRelease) GetAssets() []SourceAsset {
assets := make([]SourceAsset, len(r.Assets))
for i, asset := range r.Assets {
assets[i] = asset
}
return assets
}

var _ SourceRelease = &HttpRelease{}
201 changes: 201 additions & 0 deletions http_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright (c) 2024 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

package selfupdate

import (
"context"
"fmt"
"io"
"net/http"
"net/url"

yaml "gopkg.in/yaml.v3"
)

type HttpManifest struct {
LastReleaseID int64 `yaml:"last_release_id"`
LastAssetID int64 `yaml:"last_asset_id"`
Releases []*HttpRelease `yaml:"releases"`
}

// HttpConfig is an object to pass to NewHttpSource
type HttpConfig struct {
// BaseURL is a base URL of your update server. This parameter has NO default value.
BaseURL string
// HTTP Transport Config
Transport *http.Transport
// Additional headers
Headers http.Header
}

// HttpSource is used to load release information from an http repository
type HttpSource struct {
baseURL string
transport *http.Transport
headers http.Header
}

// NewHttpSource creates a new HttpSource from a config object.
func NewHttpSource(config HttpConfig) (*HttpSource, error) {
// Validate Base URL.
if config.BaseURL == "" {
return nil, fmt.Errorf("http base url must be set")
}
_, perr := url.ParseRequestURI(config.BaseURL)
if perr != nil {
return nil, perr
}

// Setup standard transport if not set.
if config.Transport == nil {
config.Transport = &http.Transport{}
}

// Return new source.
return &HttpSource{
baseURL: config.BaseURL,
transport: config.Transport,
headers: config.Headers,
}, nil
}

// Returns a full URI for a relative path URI.
func (s *HttpSource) uriRelative(uri, owner, repo string) string {
// If URI is blank, its blank.
if uri != "" {
// If we're able to parse the URI, a full URI is already defined.
_, perr := url.ParseRequestURI(uri)
if perr != nil {
// Join the paths if possible to make a full URI.
newURL, jerr := url.JoinPath(s.baseURL, owner, repo, uri)
if jerr == nil {
uri = newURL
}
}
}
return uri
}

// ListReleases returns all available releases
func (s *HttpSource) ListReleases(ctx context.Context, repository Repository) ([]SourceRelease, error) {
owner, repo, err := repository.GetSlug()
if err != nil {
return nil, err
}

// Make repository URI.
uri, err := url.JoinPath(s.baseURL, owner, repo, "manifest.yaml")
if err != nil {
return nil, err
}

// Setup HTTP client.
client := &http.Client{Transport: s.transport}

// Make repository request.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, http.NoBody)
if err != nil {
return nil, err
}

// Add headers to request.
req.Header = s.headers

// Perform the request.
res, err := client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
res.Body.Close()
return nil, fmt.Errorf("HTTP request failed with status code %d", res.StatusCode)
}

// Decode the response.
manifest := new(HttpManifest)
defer res.Body.Close()
decoder := yaml.NewDecoder(res.Body)
err = decoder.Decode(manifest)
if err != nil {
return nil, err
}

// Make a release array.
releases := make([]SourceRelease, len(manifest.Releases))
for i, release := range manifest.Releases {
// Update URLs to relative path with repository.
release.URL = s.uriRelative(release.URL, owner, repo)
for b, asset := range release.Assets {
release.Assets[b].URL = s.uriRelative(asset.URL, owner, repo)
}

// Set the release.
releases[i] = release
}

return releases, nil
}

// DownloadReleaseAsset downloads an asset from a release.
// It returns an io.ReadCloser: it is your responsibility to Close it.
func (s *HttpSource) DownloadReleaseAsset(ctx context.Context, rel *Release, assetID int64) (io.ReadCloser, error) {
if rel == nil {
return nil, ErrInvalidRelease
}

// Determine download url based on asset id.
var downloadUrl string
if rel.AssetID == assetID {
downloadUrl = rel.AssetURL
} else if rel.ValidationAssetID == assetID {
downloadUrl = rel.ValidationAssetURL
}
if downloadUrl == "" {
return nil, fmt.Errorf("asset ID %d: %w", assetID, ErrAssetNotFound)
}

// Setup HTTP client.
client := &http.Client{Transport: s.transport}

// Make request.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, http.NoBody)
if err != nil {
return nil, err
}

// Add headers to request.
req.Header = s.headers

// Perform the request.
response, err := client.Do(req)
if err != nil {
return nil, err
}
if response.StatusCode != http.StatusOK {
response.Body.Close()
return nil, fmt.Errorf("HTTP request failed with status code %d", response.StatusCode)
}

return response.Body, nil
}

// Verify interface
var _ Source = &HttpSource{}
Loading

0 comments on commit e15540b

Please sign in to comment.