From d4eebc634e4edaa9a8789fd7e3877b4d56423293 Mon Sep 17 00:00:00 2001 From: Ben Burwell Date: Fri, 13 Sep 2019 14:49:08 -0400 Subject: initial commit --- .gitignore | 2 + cache.go | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 5 +++ go.sum | 2 + main.go | 83 ++++++++++++++++++++++++++++++++++++++ repo_host.go | 92 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+) create mode 100644 .gitignore create mode 100644 cache.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 repo_host.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85b6ff8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +goredir diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..d43fa77 --- /dev/null +++ b/cache.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "log" + "path" + "sync" + "time" +) + +// A PackageCache holds information about Go packages and their upstream +// repositories. +// +// Given a package name, it looks for a similarly named repository in one of +// the upstream hosts and holds on to the result for quicker subsequent access. +type PackageCache struct { + // ExpireAfter is how long to consider a found repository valid for. As + // repositories do not frequently move around, this could be rather long, + // though it also determines how long a user might have to wait for a new + // repository to become available if they previously got a 404. + ExpireAfter time.Duration + + // UpstreamTimeout is how long we should allow HTTP requests to upstream + // hosts to carry on before they are canceled. + UpstreamTimeout time.Duration + + // Hosts are the RepoHosts which should be checked for a repository matching + // the package name. Hosts are checked in order; if two hosts both have a + // repo with the desired name, the repo host which is listed first will + // "win". + Hosts []RepoHost + + // Logger is a logger for diagnostics + Logger *log.Logger + + // CanonicalPrefix is the package prefix to use when naming packages. E.g., + // when looking for a package named "conf", we'll treat the package name as + // CanonicalPrefix + "/conf", e.g. burwell.io/conf. + CanonicalPrefix string + + entries sync.Map // concurrency-safe map[string]entry +} + +type entry struct { + t time.Time + pkg *Package +} + +// A Package represents a Go package's canonical name and its source git +// repository. +type Package struct { + Name string + Repo string +} + +// Get loads package name from the cache, consulting upstream repositories to +// find a matching repo if not present. As this method is meant to be called in +// the critical path of an incoming HTTP request, it can also be cancelled with +// the supplied context. +func (c *PackageCache) Get(ctx context.Context, name string) (Package, bool) { + val, ok := c.entries.Load(name) + if !ok { + return c.load(ctx, name) + } + ent := val.(entry) + + if time.Now().After(ent.t.Add(c.ExpireAfter)) { + return c.load(ctx, name) + } + + if ent.pkg == nil { + return Package{}, false + } + + return *ent.pkg, true +} + +// load looks for a package in the upstream repos, and if found, stores it in +// the cache. If no matching repo is found, a nil entry is stored in the map so +// we don't immediately re-check the upstreams for subsequent requests. +func (c *PackageCache) load(ctx context.Context, name string) (Package, bool) { + pkg, ok := c.find(ctx, name) + if !ok { + c.entries.Store(name, entry{t: time.Now()}) + return Package{}, false + } + c.entries.Store(name, entry{t: time.Now(), pkg: &pkg}) + return pkg, true +} + +// requestContext creates a context for making HTTP requests to upstream hosts. +func (c *PackageCache) requestContext(ctx context.Context) (context.Context, context.CancelFunc) { + if c.UpstreamTimeout == 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, c.UpstreamTimeout) +} + +// logf logs a message using the configured logger, if any. +func (c *PackageCache) logf(msg string, args ...interface{}) { + if c.Logger == nil { + return + } + c.Logger.Printf(msg, args...) +} + +// find consults the configured upstream hosts to try to find a matching repo +// for package name. +func (c *PackageCache) find(ctx context.Context, name string) (Package, bool) { + c.logf("loading %s", name) + for _, h := range c.Hosts { + reqCtx, cancel := c.requestContext(ctx) + defer cancel() + repo, ok, err := h.HostsRepo(reqCtx, name) + if err != nil { + c.logf("error checking upstream for repo %s: %v", name, err) + return Package{}, false + } + if ok { + c.logf("found repo %s: %s", name, repo) + return Package{ + Name: path.Join(c.CanonicalPrefix, name), + Repo: repo, + }, true + } + } + c.logf("could not find repo %s on any host", name) + return Package{}, false +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7711949 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.sr.ht/~benburwell/goredir + +go 1.13 + +require github.com/virtyx-technologies/readenv v0.1.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3a6104a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/virtyx-technologies/readenv v0.1.0 h1:Ryd/IEDH88epiWUT2WfEv1A0dJSUC0wOadRNuGMvxnc= +github.com/virtyx-technologies/readenv v0.1.0/go.mod h1:WJCkmFD7G/h27NgQE/GQaEsjrY4DEwxVL9tt/mUD1Iw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9d6f5cf --- /dev/null +++ b/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "html/template" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/virtyx-technologies/readenv" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +type options struct { + Port string `env:"PORT"` + CacheExpiry time.Duration `env:"CACHE_EXPIRY"` + UpstreamTimeout time.Duration `env:"UPSTREAM_TIMEOUT"` + SourcehutUsername string `env:"SRHT_USERNAME"` + SourcehutToken string `env:"SRHT_TOKEN"` + GithubUsername string `env:"GITHUB_USERNAME"` + GithubToken string `env:"GITHUB_TOKEN"` + CanonicalPrefix string `env:"CANONICAL_PREFIX"` +} + +func run() error { + var opts options + if err := readenv.ReadEnv(&opts); err != nil { + return err + } + cache := &PackageCache{ + CanonicalPrefix: opts.CanonicalPrefix, + ExpireAfter: opts.CacheExpiry, + UpstreamTimeout: opts.UpstreamTimeout, + Logger: log.New(os.Stdout, "", log.LstdFlags), + Hosts: []RepoHost{ + Sourcehut{Username: opts.SourcehutUsername, Token: opts.SourcehutToken}, + Github{Username: opts.GithubUsername, Token: opts.GithubToken}, + }, + } + handler := handlePackage(cache) + return http.ListenAndServe(":"+opts.Port, handler) +} + +func handlePackage(pkgs *PackageCache) http.HandlerFunc { + tmpl, err := template.New("package").Parse(` + + + + + +

{{ .Name }}

+ + +`) + if err != nil { + return nil + } + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(http.StatusText(http.StatusBadRequest))) + return + } + parts := strings.SplitN(r.URL.Path[1:], "/", 2) + pkg, ok := pkgs.Get(r.Context(), parts[0]) + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(http.StatusText(http.StatusNotFound))) + return + } + log.Printf("pkg: %v", pkg) + tmpl.Execute(w, pkg) + } +} diff --git a/repo_host.go b/repo_host.go new file mode 100644 index 0000000..2fdd4cc --- /dev/null +++ b/repo_host.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +// A RepoHost hosts repositories, and can be queried as to whether it hosts a +// repository with a specific name. +type RepoHost interface { + HostsRepo(ctx context.Context, name string) (string, bool, error) +} + +// Sourcehut is a RepoHost +// +// https://git.sr.ht +type Sourcehut struct { + Token string // a personal access token + Username string // the username +} + +// HostsRepo implements RepoHost +func (s Sourcehut) HostsRepo(ctx context.Context, name string) (string, bool, error) { + endpoint := "https://git.sr.ht/api/~" + s.Username + "/repos/" + name + type response struct { + Name string `json:"name"` + Visibility string `json:"visibility"` + } + var repo response + if err := makeRequest(ctx, &repo, endpoint, "token "+s.Token); err != nil { + return "", false, err + } + if repo.Visibility != "public" || repo.Name == "" { + return "", false, nil + } + return "https://git.sr.ht/~" + s.Username + "/" + repo.Name, true, nil +} + +// Github is a RepoHost +// +// https://github.com +type Github struct { + Username string + Token string +} + +// HostsRepo implements RepoHost +func (g Github) HostsRepo(ctx context.Context, name string) (string, bool, error) { + endpoint := "https://api.github.com/repos/" + g.Username + "/" + name + type response struct { + URL string `json:"html_url"` + Private bool `json:"private"` + } + var repo response + if err := makeRequest(ctx, &repo, endpoint, "token "+g.Token); err != nil { + return "", false, err + } + if repo.Private || repo.URL == "" { + return "", false, nil + } + return repo.URL, true, nil +} + +func makeRequest(ctx context.Context, out interface{}, url string, auth string) error { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", auth) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + if resp.StatusCode == http.StatusNotFound { + return nil + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("get %s: %s", url, resp.Status) + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + if err := json.Unmarshal(body, out); err != nil { + return err + } + return nil +} -- cgit v1.2.3