cmd/pkgsite,internal: serve source
- Set up a /files endpoint on the server that can serve
files from fs.FS implementations.
- Add source.FilesInfo, which returns a source.Info that links to
/files paths. Use it to implement the SourceInfo method of the local
ModuleGetters.
- Add a SourceFS method to the local MethodGetters so they can tell
the server how to serve their files.
- Improve the tests in cmd/pkgsite to verify the source links.
Fixes golang/go#47982
Change-Id: Iff42bad15c4abaf408a364e58b7c0f0dad60b40d
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/347932
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/cmd/pkgsite/main.go b/cmd/pkgsite/main.go
index 3ec5bf8..31e55c8 100644
--- a/cmd/pkgsite/main.go
+++ b/cmd/pkgsite/main.go
@@ -81,16 +81,16 @@
paths = []string{"."}
}
- var downloadDir string
+ var modCacheDir string
if *useCache {
- downloadDir = *cacheDir
- if downloadDir == "" {
+ modCacheDir = *cacheDir
+ if modCacheDir == "" {
var err error
- downloadDir, err = defaultCacheDir()
+ modCacheDir, err = defaultCacheDir()
if err != nil {
die("%v", err)
}
- if downloadDir == "" {
+ if modCacheDir == "" {
die("empty value for GOMODCACHE")
}
}
@@ -116,7 +116,7 @@
stdlib.SetGoRepoPath(*goRepoPath)
}
- server, err := newServer(ctx, paths, *gopathMode, downloadDir, prox)
+ server, err := newServer(ctx, paths, *gopathMode, modCacheDir, prox)
if err != nil {
die("%s\nMaybe you need to provide the location of static assets with -static.", err)
}
@@ -144,7 +144,11 @@
func newServer(ctx context.Context, paths []string, gopathMode bool, downloadDir string, prox *proxy.Client) (*frontend.Server, error) {
getters := buildGetters(ctx, paths, gopathMode)
if downloadDir != "" {
- getters = append(getters, fetch.NewFSProxyModuleGetter(downloadDir))
+ g, err := fetch.NewFSProxyModuleGetter(downloadDir)
+ if err != nil {
+ return nil, err
+ }
+ getters = append(getters, g)
}
if prox != nil {
getters = append(getters, fetch.NewProxyModuleGetter(prox, source.NewClient(time.Second)))
@@ -161,6 +165,12 @@
if err != nil {
return nil, err
}
+ for _, g := range getters {
+ p, fsys := g.SourceFS()
+ if p != "" {
+ server.InstallFS(p, fsys)
+ }
+ }
return server, nil
}
diff --git a/cmd/pkgsite/main_test.go b/cmd/pkgsite/main_test.go
index 021a56e..a938eb3 100644
--- a/cmd/pkgsite/main_test.go
+++ b/cmd/pkgsite/main_test.go
@@ -9,17 +9,40 @@
"flag"
"net/http"
"net/http/httptest"
+ "os"
+ "path"
"path/filepath"
- "strings"
+ "regexp"
"testing"
"github.com/google/go-cmp/cmp"
+ "golang.org/x/net/html"
"golang.org/x/pkgsite/internal/proxy/proxytest"
+ "golang.org/x/pkgsite/internal/testing/htmlcheck"
+)
+
+var (
+ in = htmlcheck.In
+ hasText = htmlcheck.HasText
+ attr = htmlcheck.HasAttr
+
+ // href checks for an exact match in an href attribute.
+ href = func(val string) htmlcheck.Checker {
+ return attr("href", "^"+regexp.QuoteMeta(val)+"$")
+ }
)
func Test(t *testing.T) {
repoPath := func(fn string) string { return filepath.Join("..", "..", fn) }
+ abs := func(dir string) string {
+ a, err := filepath.Abs(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return a
+ }
+
localModule := repoPath("internal/fetch/testdata/has_go_mod")
cacheDir := repoPath("internal/fetch/testdata/modcache")
flag.Set("static", repoPath("static"))
@@ -34,15 +57,37 @@
mux := http.NewServeMux()
server.Install(mux.Handle, nil, nil)
+ modcacheChecker := in("",
+ in(".Documentation", hasText("var V = 1")),
+ sourceLinks(path.Join(abs(cacheDir), "modcache.com@v1.0.0"), "a.go"))
+
for _, test := range []struct {
- name string
- url string
- wantInBody string
+ name string
+ url string
+ want htmlcheck.Checker
}{
- {"local", "example.com/testmod", "There is no documentation for this package."},
- {"modcache", "modcache.com@v1.0.0", "var V = 1"},
- {"modcache2", "modcache.com", "var V = 1"},
- {"proxy", "example.com/single/pkg", "G is new in v1.1.0"},
+ {
+ "local",
+ "example.com/testmod",
+ in("",
+ in(".Documentation", hasText("There is no documentation for this package.")),
+ sourceLinks(path.Join(abs(localModule), "example.com/testmod"), "a.go")),
+ },
+ {
+ "modcache",
+ "modcache.com@v1.0.0",
+ modcacheChecker,
+ },
+ {
+ "modcache latest",
+ "modcache.com",
+ modcacheChecker,
+ },
+ {
+ "proxy",
+ "example.com/single/pkg",
+ hasText("G is new in v1.1.0"),
+ },
} {
t.Run(test.name, func(t *testing.T) {
w := httptest.NewRecorder()
@@ -50,14 +95,28 @@
if w.Code != http.StatusOK {
t.Fatalf("got status code = %d, want %d", w.Code, http.StatusOK)
}
- body := w.Body.String()
- if !strings.Contains(body, test.wantInBody) {
- t.Fatalf("body is missing %q\n%s", test.wantInBody, body)
+ doc, err := html.Parse(w.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := test.want(doc); err != nil {
+ if testing.Verbose() {
+ html.Render(os.Stdout, doc)
+ }
+ t.Error(err)
}
})
}
}
+func sourceLinks(dir, filename string) htmlcheck.Checker {
+ filesPath := path.Join("/files", dir) + "/"
+ return in("",
+ in(".UnitMeta-repo a", href(filesPath)),
+ in(".UnitFiles-titleLink a", href(filesPath)),
+ in(".UnitFiles-fileList a", href(filesPath+filename)))
+}
+
func TestCollectPaths(t *testing.T) {
got := collectPaths([]string{"a", "b,c2,d3", "e4", "f,g"})
want := []string{"a", "b", "c2", "d3", "e4", "f", "g"}
diff --git a/internal/fetch/getters.go b/internal/fetch/getters.go
index b1a0113..02f6742 100644
--- a/internal/fetch/getters.go
+++ b/internal/fetch/getters.go
@@ -16,6 +16,7 @@
"io/fs"
"io/ioutil"
"os"
+ "path"
"path/filepath"
"strings"
"time"
@@ -46,6 +47,12 @@
// SourceInfo returns information about where to find a module's repo and
// source files.
SourceInfo(ctx context.Context, path, version string) (*source.Info, error)
+
+ // SourceFS returns the path to serve the files of the modules loaded by
+ // this ModuleGetter, and an FS that can be used to read the files. The
+ // returned values are intended to be passed to
+ // internal/frontend.Server.InstallFiles.
+ SourceFS() (string, fs.FS)
}
type proxyModuleGetter struct {
@@ -82,6 +89,12 @@
return source.ModuleInfo(ctx, g.src, path, version)
}
+// SourceFS is unimplemented for modules served from the proxy, because we
+// link directly to the module's repo.
+func (g *proxyModuleGetter) SourceFS() (string, fs.FS) {
+ return "", nil
+}
+
// Version and commit time are pre specified when fetching a local module, as these
// fields are normally obtained from a proxy.
var (
@@ -93,11 +106,12 @@
// a module's files.
type directoryModuleGetter struct {
modulePath string
- dir string
+ dir string // absolute path to direction
}
// NewDirectoryModuleGetter returns a ModuleGetter for reading a module from a directory.
func NewDirectoryModuleGetter(modulePath, dir string) (*directoryModuleGetter, error) {
+
if modulePath == "" {
goModBytes, err := ioutil.ReadFile(filepath.Join(dir, "go.mod"))
if err != nil {
@@ -108,8 +122,12 @@
return nil, fmt.Errorf("go.mod in %q has no module path: %w", dir, derrors.BadModule)
}
}
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return nil, err
+ }
return &directoryModuleGetter{
- dir: dir,
+ dir: abs,
modulePath: modulePath,
}, nil
}
@@ -154,23 +172,41 @@
return os.DirFS(g.dir), nil
}
-// TODO(golang/go#47982): implement.
-func (g *directoryModuleGetter) SourceInfo(ctx context.Context, path, version string) (*source.Info, error) {
- return nil, nil
+// SourceInfo returns a source.Info that will link to the files in the
+// directory. The files will be under /files/directory/modulePath, with no
+// version.
+func (g *directoryModuleGetter) SourceInfo(ctx context.Context, _, _ string) (*source.Info, error) {
+ return source.FilesInfo(g.fileServingPath()), nil
+}
+
+// SourceFS returns the absolute path to the directory along with a
+// filesystem FS for serving the directory.
+func (g *directoryModuleGetter) SourceFS() (string, fs.FS) {
+ return g.fileServingPath(), os.DirFS(g.dir)
+}
+
+func (g *directoryModuleGetter) fileServingPath() string {
+ return path.Join(filepath.ToSlash(g.dir), g.modulePath)
}
// An fsProxyModuleGetter gets modules from a directory in the filesystem that
// is organized like the module cache, with a cache/download directory that has
-// paths that correspond to proxy URLs. An example of such a directory is $(go
-// env GOMODCACHE).
+// paths that correspond to proxy URLs. An example of such a directory is
+// $(go env GOMODCACHE).
type fsProxyModuleGetter struct {
dir string
}
// NewFSModuleGetter return a ModuleGetter that reads modules from a filesystem
// directory organized like the proxy.
-func NewFSProxyModuleGetter(dir string) ModuleGetter {
- return &fsProxyModuleGetter{dir: dir}
+func NewFSProxyModuleGetter(dir string) (_ *fsProxyModuleGetter, err error) {
+ derrors.Wrap(&err, "NewFSProxyModuleGetter(%q)", dir)
+
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return nil, err
+ }
+ return &fsProxyModuleGetter{dir: abs}, nil
}
// Info returns basic information about the module.
@@ -182,6 +218,7 @@
if err != nil {
return nil, err
}
+ fmt.Printf("#### resolved latest to %q\n", vers)
}
// Check for a .zip file. Some directories in the download cache have .info and .mod files but no .zip.
@@ -242,9 +279,16 @@
return fs.Sub(zr, path+"@"+vers)
}
-// TODO(golang/go#47982): implement.
-func (g *fsProxyModuleGetter) SourceInfo(ctx context.Context, path, version string) (*source.Info, error) {
- return nil, nil
+// SourceInfo returns a source.Info that will create /files links to modules in
+// the cache.
+func (g *fsProxyModuleGetter) SourceInfo(ctx context.Context, mpath, version string) (*source.Info, error) {
+ return source.FilesInfo(path.Join(g.dir, mpath+"@"+version)), nil
+}
+
+// SourceFS returns the absolute path to the cache, and an FS that retrieves
+// files from it.
+func (g *fsProxyModuleGetter) SourceFS() (string, fs.FS) {
+ return filepath.ToSlash(g.dir), os.DirFS(g.dir)
}
// latestVersion gets the latest version that is in the directory.
diff --git a/internal/fetch/getters_test.go b/internal/fetch/getters_test.go
index 7c4d528..92c47ff 100644
--- a/internal/fetch/getters_test.go
+++ b/internal/fetch/getters_test.go
@@ -8,6 +8,7 @@
"context"
"errors"
"io/ioutil"
+ "path/filepath"
"testing"
"time"
@@ -46,13 +47,20 @@
"dir/cache/download/github.com/a!bc/@v/v2.3.4.zip",
},
} {
- g := NewFSProxyModuleGetter("dir").(*fsProxyModuleGetter)
+ g, err := NewFSProxyModuleGetter("dir")
+ if err != nil {
+ t.Fatal(err)
+ }
got, err := g.escapedPath(test.path, test.version, test.suffix)
if err != nil {
t.Fatal(err)
}
- if got != test.want {
- t.Errorf("%s, %s, %s: got %q, want %q", test.path, test.version, test.suffix, got, test.want)
+ want, err := filepath.Abs(test.want)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Errorf("%s, %s, %s: got %q, want %q", test.path, test.version, test.suffix, got, want)
}
}
}
@@ -68,7 +76,10 @@
if err != nil {
t.Fatal(err)
}
- g := NewFSProxyModuleGetter("testdata/modcache")
+ g, err := NewFSProxyModuleGetter("testdata/modcache")
+ if err != nil {
+ t.Fatal(err)
+ }
t.Run("info", func(t *testing.T) {
got, err := g.Info(ctx, modulePath, vers)
if err != nil {
diff --git a/internal/fetchdatasource/fetchdatasource_test.go b/internal/fetchdatasource/fetchdatasource_test.go
index b274da1..7d5ed75 100644
--- a/internal/fetchdatasource/fetchdatasource_test.go
+++ b/internal/fetchdatasource/fetchdatasource_test.go
@@ -10,6 +10,7 @@
"fmt"
"log"
"os"
+ "path"
"testing"
"time"
@@ -339,8 +340,7 @@
ctx, ds, teardown := setup(t, defaultTestModules, true)
defer teardown()
- // TODO(golang/go#47982): use the correct sourceInfo here.
- var sourceInfo *source.Info
+ sourceInfo := source.FilesInfo("XXX")
for _, test := range []struct {
path, modulePath string
@@ -441,9 +441,21 @@
if err != nil {
t.Fatal(err)
}
- if diff := cmp.Diff(test.want, got, cmp.AllowUnexported(source.Info{})); diff != "" {
+ var gotURL string
+ if got.SourceInfo != nil {
+ gotURL = got.SourceInfo.RepoURL()
+ }
+ wantURL := "/files/*/*/github.com/my/module/"
+ m, err := path.Match(wantURL, gotURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !m {
+ t.Errorf("RepoURL: got %q, want match of %q", gotURL, wantURL)
+ }
+ diff := cmp.Diff(test.want, got, cmp.AllowUnexported(source.Info{}), cmpopts.IgnoreFields(source.Info{}, "repoURL"))
+ if diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
-
}
}
})
diff --git a/internal/frontend/server.go b/internal/frontend/server.go
index 69d41e9..7e32c4f 100644
--- a/internal/frontend/server.go
+++ b/internal/frontend/server.go
@@ -11,6 +11,7 @@
"errors"
"fmt"
"io"
+ "io/fs"
"net/http"
"strings"
"sync"
@@ -48,6 +49,7 @@
googleTagManagerID string
serveStats bool
reportingClient *errorreporting.Client
+ fileMux *http.ServeMux
mu sync.Mutex // Protects all fields below
templates map[string]*template.Template
@@ -92,6 +94,7 @@
googleTagManagerID: scfg.GoogleTagManagerID,
serveStats: scfg.ServeStats,
reportingClient: scfg.ReportingClient,
+ fileMux: http.NewServeMux(),
}
errorPageBytes, err := s.renderErrorPage(context.Background(), http.StatusInternalServerError, "error", nil)
if err != nil {
@@ -148,6 +151,7 @@
http.Redirect(w, r, "/cmd/cgo", http.StatusMovedPermanently)
}))
handle("/golang.org/x", s.staticPageHandler("subrepo", "Sub-repositories"))
+ handle("/files/", http.StripPrefix("/files", s.fileMux))
handle("/", detailHandler)
if s.serveStats {
handle("/detail-stats/",
@@ -165,6 +169,11 @@
}))
}
+// InstallFS adds path under the /files handler, serving the files in fsys.
+func (s *Server) InstallFS(path string, fsys fs.FS) {
+ s.fileMux.Handle(path+"/", http.StripPrefix(path, http.FileServer(http.FS(fsys))))
+}
+
const (
// defaultTTL is used when details tab contents are subject to change, or when
// there is a problem confirming that the details can be permanently cached.
diff --git a/internal/frontend/server_test.go b/internal/frontend/server_test.go
index 771fea4..d359a48 100644
--- a/internal/frontend/server_test.go
+++ b/internal/frontend/server_test.go
@@ -1682,3 +1682,18 @@
}
}
}
+
+func TestInstallFS(t *testing.T) {
+ s, handler, teardown := newTestServer(t, nil, nil)
+ defer teardown()
+ s.InstallFS("/dir", os.DirFS("."))
+ // Request this file.
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, httptest.NewRequest("GET", "/files/dir/server_test.go", nil))
+ if w.Code != http.StatusOK {
+ t.Errorf("got status code = %d, want %d", w.Code, http.StatusOK)
+ }
+ if want := "TestInstallFS"; !strings.Contains(w.Body.String(), want) {
+ t.Errorf("body does not contain %q", want)
+ }
+}
diff --git a/internal/source/source.go b/internal/source/source.go
index be09983..e66ef4d 100644
--- a/internal/source/source.go
+++ b/internal/source/source.go
@@ -921,3 +921,21 @@
}
return info
}
+
+// FilesInfo returns an Info that links to a path in the server's /files
+// namespace. The same path needs to be installed via frontend.Server.InstallFS.
+func FilesInfo(dir string) *Info {
+ // The repo and directory patterns need a final slash. Without it,
+ // http.FileServer redirects instead of serving the directory contents, with
+ // confusing results.
+ return &Info{
+ repoURL: path.Join("/files", dir),
+ templates: urlTemplates{
+ Repo: "{repo}/",
+ Directory: "{repo}/{dir}/",
+ File: "{repo}/{file}",
+ Line: "{repo}/{file}#L{line}", // not supported now, but maybe someday
+ Raw: "{repo}/{file}",
+ },
+ }
+}
diff --git a/internal/source/source_test.go b/internal/source/source_test.go
index 0650527..6d7c5ad 100644
--- a/internal/source/source_test.go
+++ b/internal/source/source_test.go
@@ -1020,3 +1020,18 @@
}
}
}
+
+func TestFilesInfo(t *testing.T) {
+ info := FilesInfo("/Users/bob")
+
+ check := func(got, want string) {
+ t.Helper()
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ }
+
+ check(info.RepoURL(), "/files/Users/bob/")
+ check(info.ModuleURL(), "/files/Users/bob/")
+ check(info.FileURL("dir/a.go"), "/files/Users/bob/dir/a.go")
+}