blob: 45e05459e189bd5a624bb52af49e0ae895ffd07a [file] [log] [blame]
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package frontend
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/google/safehtml/template"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/experiment"
"golang.org/x/pkgsite/internal/log"
"golang.org/x/pkgsite/internal/postgres"
"golang.org/x/pkgsite/internal/stdlib"
)
// DetailsPage contains data for a package of module details template.
type DetailsPage struct {
basePage
Title string
CanShowDetails bool
Settings TabSettings
Details interface{}
Header interface{}
Breadcrumb breadcrumb
Tabs []TabSettings
// PageType is either "mod", "dir", or "pkg" depending on the details
// handler.
PageType string
}
// serveDetails handles requests for package/directory/module details pages. It
// expects paths of the form "[/mod]/<module-path>[@<version>?tab=<tab>]".
// stdlib module pages are handled at "/std", and requests to "/mod/std" will
// be redirected to that path.
func (s *Server) serveDetails(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) {
if r.Method != http.MethodGet {
return &serverError{status: http.StatusMethodNotAllowed}
}
switch r.URL.Path {
case "/":
s.staticPageHandler("index.tmpl", "")(w, r)
return nil
case "/C":
// Package "C" is a special case: redirect to the Go Blog article on cgo.
// (This is what godoc.org does.)
http.Redirect(w, r, "https://golang.org/doc/articles/c_go_cgo.html", http.StatusMovedPermanently)
return nil
case "/mod/std":
// The stdlib module page is hosted at "/std".
http.Redirect(w, r, "/std", http.StatusMovedPermanently)
return nil
}
urlInfo, err := extractURLPathInfo(r.URL.Path)
if err != nil {
return &serverError{
status: http.StatusBadRequest,
err: err,
}
}
ctx := r.Context()
// Validate the fullPath and requestedVersion that were parsed.
if err := validatePathAndVersion(ctx, ds, urlInfo.fullPath, urlInfo.requestedVersion); err != nil {
return err
}
urlInfo.resolvedVersion = urlInfo.requestedVersion
if experiment.IsActive(ctx, internal.ExperimentUsePathInfo) {
resolvedModulePath, resolvedVersion, _, err := ds.GetPathInfo(ctx, urlInfo.fullPath, urlInfo.modulePath, urlInfo.requestedVersion)
if err != nil {
if !errors.Is(err, derrors.NotFound) {
return err
}
pathType := "package"
if urlInfo.isModule {
pathType = "module"
}
return s.servePathNotFoundPage(w, r, ds, urlInfo.fullPath, urlInfo.modulePath, urlInfo.requestedVersion, pathType)
}
urlInfo.modulePath = resolvedModulePath
urlInfo.resolvedVersion = resolvedVersion
if isActivePathAtMaster(ctx) && urlInfo.requestedVersion == internal.MasterVersion {
// Since path@master is a moving target, we don't want it to be stale.
// As a result, we enqueue every request of path@master to the frontend
// task queue, which will initiate a fetch request depending on the
// last time we tried to fetch this module version.
go func() {
if err := s.queue.ScheduleFetch(ctx, urlInfo.modulePath, internal.MasterVersion, "", s.taskIDChangeInterval); err != nil {
log.Errorf(ctx, "serveDetails(%q): %v", r.URL.Path, err)
}
}()
}
}
if isActiveUseDirectories(ctx) {
return s.serveDetailsPage(w, r, ds, urlInfo)
}
return s.legacyServeDetailsPage(w, r, ds, urlInfo)
}
// serveDetailsPage serves a details page for a path using the paths,
// modules, documentation, readmes, licenses, and package_imports tables.
func (s *Server) serveDetailsPage(w http.ResponseWriter, r *http.Request, ds internal.DataSource, info *urlPathInfo) (err error) {
defer derrors.Wrap(&err, "serveDetailsPage(w, r, %v)", info)
ctx := r.Context()
vdir, err := ds.GetDirectory(ctx, info.fullPath, info.modulePath, info.resolvedVersion)
if err != nil {
return err
}
switch {
case info.isModule:
var readme *internal.Readme
if vdir.Readme != nil {
readme = &internal.Readme{Filepath: vdir.Readme.Filepath, Contents: vdir.Readme.Contents}
}
return s.serveModulePage(ctx, w, r, ds, &vdir.ModuleInfo, readme, info.requestedVersion)
case vdir.Package != nil:
return s.servePackagePage(ctx, w, r, ds, vdir, info.requestedVersion)
default:
return s.serveDirectoryPage(ctx, w, r, ds, vdir, info.requestedVersion)
}
}
// legacyServeDetailsPage serves a details page for a path using the packages,
// modules, licenses and imports tables.
func (s *Server) legacyServeDetailsPage(w http.ResponseWriter, r *http.Request, ds internal.DataSource, info *urlPathInfo) (err error) {
defer derrors.Wrap(&err, "legacyServeDetailsPage(w, r, %v)", info)
if info.isModule {
return s.legacyServeModulePage(w, r, ds, info.fullPath, info.requestedVersion, info.resolvedVersion)
}
return s.legacyServePackagePage(w, r, ds, info.fullPath, info.modulePath, info.requestedVersion, info.resolvedVersion)
}
type urlPathInfo struct {
// fullPath is the full import path corresponding to the requested
// package/module/directory page.
fullPath string
// isModule indicates whether the /mod page should be shown.
isModule bool
// modulePath is the path of the module corresponding to the fullPath and
// resolvedVersion. If unknown, it is set to internal.UnknownModulePath.
modulePath string
// requestedVersion is the version requested by the user, which will be one
// of the following: "latest", "master", a Go version tag, or a semantic
// version.
requestedVersion string
// resolvedVersion is the semantic version stored in the database.
resolvedVersion string
}
func extractURLPathInfo(urlPath string) (_ *urlPathInfo, err error) {
defer derrors.Wrap(&err, "extractURLPathInfo(%q)", urlPath)
info := &urlPathInfo{}
if strings.HasPrefix(urlPath, "/mod/") {
urlPath = strings.TrimPrefix(urlPath, "/mod")
info.isModule = true
}
// Parse the fullPath, modulePath and requestedVersion, based on whether
// the path is in the stdlib. If unable to parse these elements, return
// http.StatusBadRequest.
if parts := strings.SplitN(strings.TrimPrefix(urlPath, "/"), "@", 2); stdlib.Contains(parts[0]) {
info.fullPath, info.requestedVersion, err = parseStdLibURLPath(urlPath)
info.modulePath = stdlib.ModulePath
if info.fullPath == stdlib.ModulePath {
info.isModule = true
}
} else {
info.fullPath, info.modulePath, info.requestedVersion, err = parseDetailsURLPath(urlPath)
}
if err != nil {
return nil, err
}
return info, nil
}
// parseDetailsURLPath parses a URL path that refers (or may refer) to something
// in the Go ecosystem.
//
// After trimming leading and trailing slashes, the path is expected to have one
// of three forms, and we divide it into three parts: a full path, a module
// path, and a version.
//
// 1. The path has no '@', like github.com/hashicorp/vault/api.
// This is the full path. The module path is unknown. So is the version, so we
// treat it as the latest version for whatever the path denotes.
//
// 2. The path has "@version" at the end, like github.com/hashicorp/vault/api@v1.2.3.
// We split this at the '@' into a full path (github.com/hashicorp/vault/api)
// and version (v1.2.3); the module path is still unknown.
//
// 3. The path has "@version" in the middle, like github.com/hashicorp/vault@v1.2.3/api.
// (We call this the "canonical" form of a path.)
// We remove the version to get the full path, which is again
// github.com/hashicorp/vault/api. The version is v1.2.3, and the module path is
// the part before the '@', github.com/hashicorp/vault.
//
// In one case, we do a little more than parse the urlPath into parts: if the full path
// could be a part of the standard library (because it has no '.'), we assume it
// is and set the modulePath to indicate the standard library.
func parseDetailsURLPath(urlPath string) (fullPath, modulePath, requestedVersion string, err error) {
defer derrors.Wrap(&err, "parseDetailsURLPath(%q)", urlPath)
// This splits urlPath into either:
// /<module-path>[/<suffix>]
// or
// /<module-path>, @<version>/<suffix>
// or
// /<module-path>/<suffix>, @<version>
parts := strings.SplitN(urlPath, "@", 2)
basePath := strings.TrimSuffix(strings.TrimPrefix(parts[0], "/"), "/")
if len(parts) == 1 { // no '@'
modulePath = internal.UnknownModulePath
requestedVersion = internal.LatestVersion
fullPath = basePath
} else {
// Parse the version and suffix from parts[1], the string after the '@'.
endParts := strings.Split(parts[1], "/")
suffix := strings.Join(endParts[1:], "/")
// The first path component after the '@' is the version.
requestedVersion = endParts[0]
// You cannot explicitly write "latest" for the version.
if requestedVersion == internal.LatestVersion {
return "", "", "", fmt.Errorf("invalid version: %q", requestedVersion)
}
if suffix == "" {
// "@version" occurred at the end of the path; we don't know the module path.
modulePath = internal.UnknownModulePath
fullPath = basePath
} else {
// "@version" occurred in the middle of the path; the part before it
// is the module path.
modulePath = basePath
fullPath = basePath + "/" + suffix
}
}
// The full path must be a valid import path (that is, package path), even if it denotes
// a module, directory or collection.
if err := module.CheckImportPath(fullPath); err != nil {
return "", "", "", fmt.Errorf("malformed path %q: %v", fullPath, err)
}
// If the full path is (or could be) in the standard library, change the
// module path to say so. But in that case, disallow versions in the middle,
// like "net@go1.14/http". That says that the module is "net", and it isn't.
if stdlib.Contains(fullPath) {
if modulePath != internal.UnknownModulePath {
return "", "", "", fmt.Errorf("non-final version in standard library path %q", urlPath)
}
modulePath = stdlib.ModulePath
}
return fullPath, modulePath, requestedVersion, nil
}
// validatePathAndVersion verifies that the requested path and version are
// acceptable. The given path may be a module or package path.
func validatePathAndVersion(ctx context.Context, ds internal.DataSource, fullPath, requestedVersion string) error {
if !isSupportedVersion(ctx, fullPath, requestedVersion) {
return &serverError{
status: http.StatusBadRequest,
epage: &errorPage{
messageTemplate: template.MakeTrustedTemplate(`
<h3 class="Error-message">{{.Version}} is not a valid semantic version.</h3>
<p class="Error-message">
To search for packages like {{.Path}}, <a href="/search?q={{.Path}}">click here</a>.
</p>`),
MessageData: struct{ Path, Version string }{fullPath, requestedVersion},
},
}
}
db, ok := ds.(*postgres.DB)
if !ok {
return nil
}
excluded, err := db.IsExcluded(ctx, fullPath)
if err != nil {
return err
}
if excluded {
// Return NotFound; don't let the user know that the package was excluded.
return &serverError{status: http.StatusNotFound}
}
return nil
}
// isSupportedVersion reports whether the version is supported by the frontend.
func isSupportedVersion(ctx context.Context, fullPath, requestedVersion string) bool {
if stdlib.Contains(fullPath) && requestedVersion == internal.MasterVersion {
return false
}
if requestedVersion == internal.LatestVersion || semver.IsValid(requestedVersion) {
return true
}
if isActivePathAtMaster(ctx) {
return requestedVersion == internal.MasterVersion
}
return false
}
// isActveUseDirectories reports whether the experiment for reading from the
// paths-based data model is active.
func isActiveUseDirectories(ctx context.Context) bool {
return experiment.IsActive(ctx, internal.ExperimentUseDirectories)
}
// isActivePathAtMaster reports whether the experiment for viewing packages at
// master is active.
func isActivePathAtMaster(ctx context.Context) bool {
return experiment.IsActive(ctx, internal.ExperimentMasterVersion) &&
isActiveFrontendFetch(ctx)
}
// pathNotFoundError returns an error page with instructions on how to
// add a package or module to the site. pathType is always either the string
// "package" or "module".
func pathNotFoundError(ctx context.Context, pathType, fullPath, requestedVersion string) error {
if isActiveFrontendFetch(ctx) {
return pathNotFoundErrorNew(fullPath, requestedVersion)
}
return &serverError{
status: http.StatusNotFound,
epage: &errorPage{
messageTemplate: template.MakeTrustedTemplate(`<h3 class="Error-message">404 Not Found</h3>
<p class="Error-message">
If you think this is a valid {{.}} path, you can try fetching it following
the <a href="/about#adding-a-package">instructions here</a>.
</p>`),
MessageData: pathType,
},
}
}
// pathNotFoundErrorNew returns an error page that provides the user with an
// option to fetch a path.
func pathNotFoundErrorNew(fullPath, requestedVersion string) error {
if stdlib.Contains(fullPath) {
return &serverError{status: http.StatusNotFound}
}
path := fullPath
if requestedVersion != internal.LatestVersion {
path = fmt.Sprintf("%s@%s", fullPath, requestedVersion)
}
return &serverError{
status: http.StatusNotFound,
epage: &errorPage{
templateName: "fetch.tmpl",
messageTemplate: template.MakeTrustedTemplate(`
<h3 class="NotFound-message">Oops! {{.}} does not exist.</h3>
<p class="NotFound-message js-notFoundMessage">
Check that you entered it correctly, or request to fetch it.
</p>`),
MessageData: path,
},
}
}
// pathFoundAtLatestError returns an error page when the fullPath exists, but
// the version that is requested does not.
func pathFoundAtLatestError(ctx context.Context, pathType, fullPath, requestedVersion string) error {
if isActiveFrontendFetch(ctx) {
return pathNotFoundErrorNew(fullPath, requestedVersion)
}
return &serverError{
status: http.StatusNotFound,
epage: &errorPage{
messageTemplate: template.MakeTrustedTemplate(`
<h3 class="Error-message">{{.TType}} {{.Path}}@{{.Version}} is not available.</h3>
<p class="Error-message">
There are other versions of this {{.Type}} that are! To view them,
<a href="/{{.Path}}?tab=versions">click here</a>.
</p>`),
MessageData: struct{ TType, Type, Path, Version string }{
strings.Title(pathType), pathType, fullPath, displayVersion(requestedVersion, fullPath)},
},
}
}
func proxydatasourceNotSupportedErr() error {
return &serverError{
status: http.StatusFailedDependency,
epage: &errorPage{
messageTemplate: template.MakeTrustedTemplate(
`<h3 class="Error-message">This page is not supported by the proxydatasource.</h3>`),
},
}
}
func parseStdLibURLPath(urlPath string) (path, requestedVersion string, err error) {
defer derrors.Wrap(&err, "parseStdLibURLPath(%q)", urlPath)
// This splits urlPath into either:
// /<path>@<tag> or /<path>
parts := strings.SplitN(urlPath, "@", 2)
path = strings.TrimSuffix(strings.TrimPrefix(parts[0], "/"), "/")
if err := module.CheckImportPath(path); err != nil {
return "", "", err
}
if len(parts) == 1 {
return path, internal.LatestVersion, nil
}
requestedVersion = stdlib.VersionForTag(strings.TrimSuffix(parts[1], "/"))
if requestedVersion == "" {
return "", "", fmt.Errorf("invalid Go tag for url: %q", urlPath)
}
return path, requestedVersion, nil
}
func (s *Server) servePathNotFoundPage(w http.ResponseWriter, r *http.Request, ds internal.DataSource, fullPath, modulePath, requestedVersion, pathType string) (err error) {
defer derrors.Wrap(&err, "servePathNotFoundPage(w, r, %q, %q)", fullPath, requestedVersion)
ctx := r.Context()
path, err := stdlibPathForShortcut(ctx, ds, fullPath)
if err != nil {
// Log the error, but prefer a "path not found" error for a
// better user experience.
log.Error(ctx, err)
}
if path != "" {
http.Redirect(w, r, fmt.Sprintf("/%s", path), http.StatusFound)
return
}
if isActiveFrontendFetch(ctx) && !stdlib.Contains(fullPath) {
db, ok := ds.(*postgres.DB)
if !ok {
return pathNotFoundError(ctx, pathType, fullPath, requestedVersion)
}
modulePaths, err := modulePathsToFetch(ctx, db, fullPath, modulePath)
if err != nil {
return pathNotFoundError(ctx, pathType, fullPath, requestedVersion)
}
results := s.checkPossibleModulePaths(ctx, db, fullPath, requestedVersion, modulePaths, false)
for _, fr := range results {
if fr.status == statusNotFoundInVersionMap {
// If the result is statusNotFoundInVersionMap, it means that
// we haven't attempted to fetch this path before. Return an
// error page giving the user the option to fetch the path.
return pathNotFoundErrorNew(fullPath, requestedVersion)
}
}
status, responseText := fetchRequestStatusAndResponseText(results, fullPath, requestedVersion)
return &serverError{
status: status,
epage: &errorPage{
messageTemplate: template.MakeTrustedTemplate(`
<h3 class="Error-message">{{.StatusText}}</h3>
<p class="Error-message">{{.Response}}</p>`),
MessageData: struct{ StatusText, Response string }{http.StatusText(status), responseText},
},
}
}
if requestedVersion == internal.LatestVersion || isActiveFrontendFetch(ctx) {
// We already know that the fullPath does not exist at any version.
//
// If frontend fetch is enabled always show the 404 page so that the
// user can request the version that they want.
return pathNotFoundError(ctx, pathType, fullPath, requestedVersion)
}
// If frontend fetch is not enabled and we couldn't find a path at the
// given version, but if there's one at the latest version we can provide a
// link to it.
if _, _, _, err := ds.GetPathInfo(ctx, fullPath, modulePath, internal.LatestVersion); err != nil {
if errors.Is(err, derrors.NotFound) {
return pathNotFoundError(ctx, pathType, fullPath, requestedVersion)
}
return err
}
return pathFoundAtLatestError(ctx, pathType, fullPath, requestedVersion)
}