diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9481fe1 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,23 @@ +name: Build Docker Image + +on: + push: + tags: + - '*' + +jobs: + build-source: + runs-on: ubuntu-latest + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:${{ github.ref_name }},${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d904686 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/config.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf9ae81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.22 + +WORKDIR /app + +COPY cmd/ cmd/ +COPY internal/ internal/ + +COPY go.mod go.mod +COPY go.sum go.sum +RUN go mod download + +RUN go install -v /app/cmd/service + +CMD ["service"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3e589c2 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: help +help: # Display this help + @awk 'BEGIN{FS=":.*#";printf "Usage:\n make \n\nTargets:\n"}/^[a-zA-Z_-]+:.*?#/{printf" %-10s %s\n",$$1,$$2}' $(MAKEFILE_LIST) + +.PHONY: build +build: # Build service to bin/ directory + go build -o bin/service cmd/service/main.go + +.PHONY: run +run: build # Runs service after build + ./bin/service diff --git a/cmd/service/main.go b/cmd/service/main.go new file mode 100644 index 0000000..a162912 --- /dev/null +++ b/cmd/service/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "net/http" + "os" + "time" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/gorilla/mux" + + "github.com/ATOR-Development/anon-download-links/internal/api" + "github.com/ATOR-Development/anon-download-links/internal/config" + "github.com/ATOR-Development/anon-download-links/internal/downloads" +) + +var ( + configFile = flag.String("config", "config.yml", "Config file.") + listenAddress = flag.String("listen-address", ":8080", "Exporter HTTP listen address.") +) + +func main() { + flag.Parse() + + logger := log.NewLogfmtLogger(os.Stderr) + logger = log.WithPrefix(logger, "ts", log.TimestampFormat(time.Now, time.Stamp)) + + level.Info(logger).Log("msg", "initializing service from", "config", *configFile) + + cfg, err := config.FromFile(*configFile) + if err != nil { + level.Error(logger).Log("msg", "cannot read config", "err", err.Error()) + os.Exit(1) + } + + downloads, err := downloads.New(cfg, logger) + if err != nil { + level.Error(logger).Log("msg", "cannot create downloads service", "err", err.Error()) + os.Exit(1) + } + + level.Info(logger).Log("msg", "starting http server", "listen", *listenAddress) + + api := api.New(downloads, logger) + + router := mux.NewRouter() + router.HandleFunc("/api/downloads", api.HandleDownloads).Methods("GET") + router.HandleFunc("/download/{name}", api.HandleDownload).Methods("GET") + + http.Handle("/", router) + http.ListenAndServe(*listenAddress, nil) +} diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..72f56b9 --- /dev/null +++ b/config.example.yml @@ -0,0 +1,26 @@ +owner: ATOR-Development +repo: ator-protocol +cachePeriod: 1m +artifacts: + - name: macos-amd64 + regexp: '^anon-live-macos-amd64.+' + - name: macos-arm64 + regexp: '^anon-live-macos-arm64.+' + - name: windows-amd64 + regexp: '^anon-live-windows-amd64.+' + - name: debian-bullseye-amd64 + regexp: '^anon.+-live-.+bullseye.+amd64\.deb' + - name: debian-bullseye-arm64 + regexp: '^anon.+-live-.+bullseye.+arm64\.deb' + - name: debian-bookworm-amd64 + regexp: '^anon.+-live-.+bookworm.+amd64\.deb' + - name: debian-bookworm-arm64 + regexp: '^anon.+-live-.+bookworm.+arm64\.deb' + - name: ubuntu-focal-amd64 + regexp: '^anon.+-live-.+focal.+amd64\.deb' + - name: ubuntu-focal-arm64 + regexp: '^anon.+-live-.+focal.+arm64\.deb' + - name: ubuntu-jammy-amd64 + regexp: '^anon.+-live-.+jammy.+amd64\.deb' + - name: ubuntu-jammy-arm64 + regexp: '^anon.+-live-.+jammy.+arm64\.deb' diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d136c9 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/ATOR-Development/anon-download-links + +go 1.20 + +require ( + github.com/go-kit/log v0.2.1 + github.com/gorilla/mux v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require github.com/go-logfmt/logfmt v0.5.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..485da6e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..cec1520 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,73 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/ATOR-Development/anon-download-links/internal/downloads" + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/gorilla/mux" +) + +type API struct { + downloads *downloads.Downloads + + logger log.Logger +} + +func New(downloads *downloads.Downloads, logger log.Logger) *API { + return &API{ + downloads: downloads, + + logger: log.WithPrefix(logger, "service", "api"), + } +} + +func (a *API) HandleDownloads(w http.ResponseWriter, r *http.Request) { + level.Error(a.logger).Log("msg", "handling downloads") + + artifacts, err := a.downloads.GetArtifacts(r.Context()) + if err != nil { + level.Error(a.logger).Log("msg", "unable to get artifacts", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + respBytes, err := json.Marshal(artifacts) + if err != nil { + level.Error(a.logger).Log("msg", "unable to marshal artifacts", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Write(respBytes) +} + +func (a *API) HandleDownload(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + name, ok := vars["name"] + if !ok { + w.WriteHeader(http.StatusBadRequest) + return + } + + level.Error(a.logger).Log("msg", "handling download", "name", name) + + artifactsMap, err := a.downloads.GetArtifactsMap(r.Context()) + if err != nil { + level.Error(a.logger).Log("msg", "unable to get artifacts map", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + downloadURL, ok := artifactsMap[name] + if !ok { + level.Warn(a.logger).Log("msg", "download not found", "name", name) + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("download not found")) + return + } + + http.Redirect(w, r, downloadURL, http.StatusFound) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..671ac15 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,46 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Owner string `yaml:"owner"` + Repo string `yaml:"repo"` + CachePeriod string `yaml:"cachePeriod"` + Artifacts []Artifact `yaml:"artifacts"` +} + +type Artifact struct { + Name string `yaml:"name"` + Regexp string `yaml:"regexp"` +} + +// New creates new Config from config data +func New(data []byte) (*Config, error) { + var cfg Config + err := yaml.Unmarshal(data, &cfg) + if err != nil { + return nil, err + } + + return &cfg, nil +} + +// FromFile reads filename and creates Config from it +func FromFile(filename string) (*Config, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + cfg, err := New(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", filename, err) + } + + return cfg, nil +} diff --git a/internal/downloads/downloads.go b/internal/downloads/downloads.go new file mode 100644 index 0000000..2aca699 --- /dev/null +++ b/internal/downloads/downloads.go @@ -0,0 +1,159 @@ +package downloads + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "sync" + "time" + + "github.com/ATOR-Development/anon-download-links/internal/config" + "github.com/go-kit/log" + "github.com/go-kit/log/level" +) + +type Artifact struct { + Name string `json:"name"` + DownloadURL string `json:"download_url"` +} + +type Downloads struct { + owner string + repo string + releases []*release + cachePeriod time.Duration + + logger log.Logger + + latestUpdate time.Time + latestArtifacts []*Artifact + latestArtifactsMutex sync.Mutex +} + +func New(cfg *config.Config, logger log.Logger) (*Downloads, error) { + var releases []*release + for _, r := range cfg.Artifacts { + regexp, err := regexp.Compile(r.Regexp) + if err != nil { + return nil, fmt.Errorf("release regexp (%s): %w", r.Name, err) + } + + releases = append(releases, &release{ + name: r.Name, + regexp: regexp, + }) + } + + cachePeriod, err := time.ParseDuration(cfg.CachePeriod) + if err != nil { + return nil, fmt.Errorf("cache period parse: %w", err) + } + + return &Downloads{ + owner: cfg.Owner, + repo: cfg.Repo, + releases: releases, + cachePeriod: cachePeriod, + + logger: logger, + }, nil +} + +func (d *Downloads) GetArtifacts(ctx context.Context) ([]*Artifact, error) { + // TODO: Cache results for N seconds/minutes + return d.fetchArtifacts(ctx) +} + +func (d *Downloads) GetArtifactsMap(ctx context.Context) (map[string]string, error) { + artifacts, err := d.fetchArtifacts(ctx) + if err != nil { + return nil, err + } + + artifactsMap := make(map[string]string) + for _, a := range artifacts { + artifactsMap[a.Name] = a.DownloadURL + } + + return artifactsMap, nil +} + +func (d *Downloads) fetchArtifacts(ctx context.Context) ([]*Artifact, error) { + d.latestArtifactsMutex.Lock() + defer d.latestArtifactsMutex.Unlock() + + if d.latestUpdate.Add(d.cachePeriod).Compare(time.Now()) > 0 { + d.logger.Log("msg", "cache hit") + return d.latestArtifacts, nil + } else { + d.logger.Log("msg", "cache miss") + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", d.owner, d.repo) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req = req.WithContext(ctx) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var githubReleaseResp githubRelease + err = json.Unmarshal(respData, &githubReleaseResp) + if err != nil { + return nil, err + } + + var artifacts []*Artifact + for _, r := range d.releases { + matches := 0 + var artifact *Artifact + for _, a := range githubReleaseResp.Assets { + if r.regexp.MatchString(a.Name) { + artifact = &Artifact{ + Name: r.name, + DownloadURL: a.BrowserDownloadURL, + } + matches++ + } + } + + if artifact != nil { + if matches != 1 { + level.Warn(d.logger).Log("msg", "unexpected artifacts count", "name", r.name, "count", matches) + } + artifacts = append(artifacts, artifact) + } else { + level.Warn(d.logger).Log("msg", "no artifacts found", "name", r.name) + } + } + + if len(artifacts) == 0 { + return nil, errors.New("no matched artifacts were found in latest release") + } + + d.latestUpdate = time.Now() + d.latestArtifacts = artifacts + + return artifacts, nil +} + +type release struct { + name string + regexp *regexp.Regexp +} diff --git a/internal/downloads/github.go b/internal/downloads/github.go new file mode 100644 index 0000000..a2b8976 --- /dev/null +++ b/internal/downloads/github.go @@ -0,0 +1,17 @@ +package downloads + +import "time" + +type githubRelease struct { + HTMLURL string `json:"html_url"` + TagName string `json:"tag_name"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + Assets []githubReleaseAsset `json:"assets"` +} + +type githubReleaseAsset struct { + Name string `json:"name"` + Size int `json:"size"` + BrowserDownloadURL string `json:"browser_download_url"` +}