Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for http based repositories #42

Merged
merged 10 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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 @@ -379,6 +381,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"`
}
GRMrGecko marked this conversation as resolved.
Show resolved Hide resolved

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