summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Burwell <ben@benburwell.com>2019-09-13 14:49:08 -0400
committerBen Burwell <ben@benburwell.com>2019-09-13 14:58:57 -0400
commitd4eebc634e4edaa9a8789fd7e3877b4d56423293 (patch)
tree4044f97bcfc22677382b56727d1ecb240ad87f6c
initial commit0.1.0
-rw-r--r--.gitignore2
-rw-r--r--cache.go129
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--main.go83
-rw-r--r--repo_host.go92
6 files changed, 313 insertions, 0 deletions
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(`<!DOCTYPE html>
+<html>
+<head>
+<meta name="go-import" content="{{ .Name }} git {{ .Repo }}">
+</head>
+<body>
+<h1>{{ .Name }}</h1>
+<ul>
+<li><a href="https://godoc.org/{{ .Name }}">Godoc</a></li>
+<li><a href="{{ .Repo }}">Code</a></li>
+</ul>
+</body>
+</html>`)
+ 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
+}