diff --git a/config/config.go b/config/config.go index 2962154..a301e12 100644 --- a/config/config.go +++ b/config/config.go @@ -15,14 +15,16 @@ const Prefix = "vanity" // Configures the vanityd server from the environment. type Config struct { - Maintenance bool `default:"false" desc:"if true, the server will start in maintenance mode"` - LogLevel logger.LevelDecoder `split_words:"true" default:"info" desc:"specify the verbosity of logging (trace, debug, info, warn, error, fatal panic)"` - ConsoleLog bool `split_words:"true" default:"false" desc:"if true logs colorized human readable output instead of json"` - BindAddr string `split_words:"true" default:":3264" desc:"the ip address and port to bind the server on"` - ReadTimeout time.Duration `split_words:"true" default:"20s" desc:"amount of time allowed to read request headers before server decides the request is too slow"` - WriteTimeout time.Duration `split_words:"true" default:"20s" desc:"maximum amount of time before timing out a write to a response"` - IdleTimeout time.Duration `split_words:"true" default:"10m" desc:"maximum amount of time to wait for the next request while keep alives are enabled"` - processed bool + Maintenance bool `default:"false" desc:"if true, the server will start in maintenance mode"` + Domain string `default:"" desc:"specify the domain of the vanity URLs, otherwise the request host will be used"` + DefaultBranch string `split_words:"true" default:"main" desc:"specify the default branch to use for repository references"` + LogLevel logger.LevelDecoder `split_words:"true" default:"info" desc:"specify the verbosity of logging (trace, debug, info, warn, error, fatal panic)"` + ConsoleLog bool `split_words:"true" default:"false" desc:"if true logs colorized human readable output instead of json"` + BindAddr string `split_words:"true" default:":3264" desc:"the ip address and port to bind the server on"` + ReadTimeout time.Duration `split_words:"true" default:"20s" desc:"amount of time allowed to read request headers before server decides the request is too slow"` + WriteTimeout time.Duration `split_words:"true" default:"20s" desc:"maximum amount of time before timing out a write to a response"` + IdleTimeout time.Duration `split_words:"true" default:"10m" desc:"maximum amount of time to wait for the next request while keep alives are enabled"` + processed bool } func New() (conf Config, err error) { diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..e3fddfc --- /dev/null +++ b/errors.go @@ -0,0 +1,9 @@ +package vanity + +import "errors" + +var ( + ErrNoRepository = errors.New("a repository url is required") + ErrInvalidRepository = errors.New("expected repository url in the form vcsScheme://vcsHost/user/repo") + ErrInvalidProtocol = errors.New("protocol must be git, github, or gogs") +) diff --git a/logger/middleware.go b/logger/middleware.go index 0c9c877..6942958 100644 --- a/logger/middleware.go +++ b/logger/middleware.go @@ -5,13 +5,11 @@ import ( "time" "github.com/julienschmidt/httprouter" - "github.com/rotationalio/vanity" "github.com/rotationalio/vanity/server/middleware" "github.com/rs/zerolog/log" ) -func HTTPLogger(server string) middleware.Middleware { - version := vanity.Version() +func HTTPLogger(server, version string) middleware.Middleware { return func(next httprouter.Handle) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { // Before the request diff --git a/server/routes.go b/server/routes.go index 7c9c18d..52d6e4c 100644 --- a/server/routes.go +++ b/server/routes.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/julienschmidt/httprouter" + "github.com/rotationalio/vanity" "github.com/rotationalio/vanity/logger" "github.com/rotationalio/vanity/server/middleware" ) @@ -12,7 +13,7 @@ import ( // Sets up the server's middleware and routes. func (s *Server) setupRoutes() (err error) { middleware := []middleware.Middleware{ - logger.HTTPLogger("vanity"), + logger.HTTPLogger("vanity", vanity.Version()), s.Maintenance(), } @@ -31,6 +32,10 @@ func (s *Server) setupRoutes() (err error) { s.addRoute(http.MethodGet, "/", s.HomePage(), middleware...) // Golang Vanity Handling + pkg := &vanity.GoPackage{Domain: "go.rotational.io", Repository: "https://github.com/rotationalio/confire"} + pkg.Resolve(nil) + s.addRoute(http.MethodGet, "/rotationalio/confire", Vanity(pkg), middleware...) + s.addRoute(http.MethodGet, "/rotationalio/confire/*filepath", Vanity(pkg), middleware...) return nil } diff --git a/server/static/css/style.css b/server/static/css/style.css index 778208e..82184f5 100644 --- a/server/static/css/style.css +++ b/server/static/css/style.css @@ -34,4 +34,8 @@ header div { padding: 24px; margin: 0; font-size: 24px; +} + +.redirect { + padding: 32px 0; } \ No newline at end of file diff --git a/server/templates/vanity.html b/server/templates/vanity.html new file mode 100644 index 0000000..3ad018c --- /dev/null +++ b/server/templates/vanity.html @@ -0,0 +1,23 @@ + + + + + + + + + + + Rotational Go Packages + + + + + +
+
+ If not automatically redirected please click: {{ .Redirect }}. +
+
+ + \ No newline at end of file diff --git a/server/vanity.go b/server/vanity.go new file mode 100644 index 0000000..4e21f2d --- /dev/null +++ b/server/vanity.go @@ -0,0 +1,33 @@ +package server + +import ( + "html/template" + "io/fs" + "net/http" + + "github.com/julienschmidt/httprouter" + "github.com/rotationalio/vanity" +) + +func Vanity(pkg *vanity.GoPackage) httprouter.Handle { + // Compile the template for serving the vanity url. + templates, _ := fs.Sub(content, "templates") + index := template.Must(template.ParseFS(templates, "*.html")) + + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + // Construct a request specific response + data := pkg.WithRequest(r) + + // Issue an HTTP redirect if this is definitely a browser. + if r.FormValue("go-get") != "1" { + http.Redirect(w, r, data.Redirect(), http.StatusTemporaryRedirect) + return + } + + // Write go-import and go-source meta tags to response. + w.Header().Set("Cache-Control", "public, max-age=300") + if err := index.ExecuteTemplate(w, "vanity.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} diff --git a/vanity.go b/vanity.go new file mode 100644 index 0000000..9de4c7f --- /dev/null +++ b/vanity.go @@ -0,0 +1,169 @@ +package vanity + +import ( + "net/http" + "net/url" + "path/filepath" + "strings" + + "github.com/rotationalio/vanity/config" +) + +const ( + protocolGit = "git" + protocolGitHub = "github" + protocolGOGS = "gogs" +) + +var godoc *url.URL = &url.URL{ + Scheme: "https", + Host: "godoc.org", +} + +var validProtocols = map[string]struct{}{ + protocolGit: {}, + protocolGitHub: {}, + protocolGOGS: {}, +} + +type GoPackage struct { + Domain string `json:"-"` // the vanity URL domain to use + Module string `json:"-"` // the module name where go.mod is located; parsed from the repository + Package string `json:"-"` // the full package path being requested for correct redirects + Protocol string `json:"protocol"` // can be "git", "github", or "gogs" -- defaults to "git" + Repository string `json:"repository"` // a path to the public repository starting with https:// + Branch string `json:"branch"` // the name of the default branch -- defaults to "main" + repo *url.URL `json:"-"` // the parsed repository URL + user string `json:"-"` // the user or organization from the repository +} + +func (p *GoPackage) Resolve(conf *config.Config) (err error) { + // Verify there is a repository + if p.Repository == "" { + return ErrNoRepository + } + + // Parse the repository + if p.repo, err = url.Parse(p.Repository); err != nil { + return ErrInvalidRepository + } + + parts := strings.Split(p.repo.Path, "/") + if len(parts) != 3 { + return ErrInvalidRepository + } + + p.user = parts[1] + p.Module = parts[2] + + // Check protocol + if p.Protocol == "" { + p.Protocol = protocolGit + } + + if _, ok := validProtocols[p.Protocol]; !ok { + return ErrInvalidProtocol + } + + // Manage the configuration + if conf != nil { + p.Domain = conf.Domain + + if p.Branch == "" { + p.Branch = conf.DefaultBranch + } + } + + // Check the ref + if p.Branch == "" { + p.Branch = "main" + } + + return nil +} + +func (p *GoPackage) WithRequest(r *http.Request) GoPackage { + pkg := p.Module + if r != nil { + pkg = r.URL.Path + } + + clone := GoPackage{ + Domain: p.Domain, + Module: p.Module, + Package: pkg, + Protocol: p.Protocol, + Repository: p.Repository, + Branch: p.Branch, + repo: p.repo, + user: p.user, + } + + if clone.Domain == "" && r != nil { + clone.Domain = r.Host + } + + return clone +} + +func (p GoPackage) Redirect() string { + return godoc.ResolveReference( + &url.URL{ + Path: filepath.Join("/", p.Domain, p.Package), + }, + ).String() +} + +func (p GoPackage) GoImportMeta() string { + parts := []string{ + p.Import(), + p.Protocol, + p.repo.String(), + } + + return strings.Join(parts, " ") +} + +func (p GoPackage) GoSourceMeta() string { + parts := []string{ + p.Import(), + p.repo.String(), + "", + "", + } + parts[2], parts[3] = p.Source() + return strings.Join(parts, " ") +} + +func (p GoPackage) Import() string { + return filepath.Join(p.Domain, p.Module) +} + +func (p GoPackage) Source() (string, string) { + switch p.Protocol { + case protocolGit, protocolGitHub: + return p.githubSource() + case protocolGOGS: + return p.gogsSource() + default: + return "", "" + } +} + +func (p GoPackage) githubSource() (string, string) { + base := filepath.Join(p.user, p.Module) + directoryPath := filepath.Join(base, "tree", p.Branch+"{/dir}") + filePath := filepath.Join(base, "blob", p.Branch+"{/dir}", "{file}#L{line}") + + uri := p.repo.ResolveReference(&url.URL{Path: "/"}).String() + return uri + directoryPath, uri + filePath +} + +func (p GoPackage) gogsSource() (string, string) { + base := filepath.Join(p.user, p.Module) + directoryPath := filepath.Join(base, "src", p.Branch+"{/dir}") + filePath := filepath.Join(base, "src", p.Branch+"{/dir}", "{file}#L{line}") + + uri := p.repo.ResolveReference(&url.URL{Path: "/"}).String() + return uri + directoryPath, uri + filePath +} diff --git a/vanity_test.go b/vanity_test.go new file mode 100644 index 0000000..6029fe5 --- /dev/null +++ b/vanity_test.go @@ -0,0 +1,52 @@ +package vanity_test + +import ( + "net/http" + "net/url" + "testing" + + . "github.com/rotationalio/vanity" + "github.com/rotationalio/vanity/config" + "github.com/stretchr/testify/require" +) + +type expected struct { + redirect string + importMeta string + sourceMeta string +} + +func TestGoPackage(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + testCases := []struct { + in *GoPackage + conf *config.Config + req *http.Request + expected expected + }{ + { + in: &GoPackage{Repository: "https://github.com/rotationalio/confire"}, + conf: &config.Config{Domain: "go.rotational.io", DefaultBranch: "main"}, + req: &http.Request{URL: &url.URL{Path: "/confire/validate"}}, + expected: expected{ + redirect: "https://godoc.org/go.rotational.io/confire/validate", + importMeta: "go.rotational.io/confire git https://github.com/rotationalio/confire", + sourceMeta: "go.rotational.io/confire https://github.com/rotationalio/confire https://github.com/rotationalio/confire/tree/main{/dir} https://github.com/rotationalio/confire/blob/main{/dir}/{file}#L{line}", + }, + }, + } + + for i, tc := range testCases { + // Resolve the go package data + require.NoError(t, tc.in.Resolve(tc.conf), "test case %d failed: could not resolve", i) + + // Finalize package for the request + pkg := tc.in.WithRequest(tc.req) + + // Perform assertions + require.Equal(t, tc.expected.redirect, pkg.Redirect(), "test case %d failed: bad redirect", i) + require.Equal(t, tc.expected.importMeta, pkg.GoImportMeta(), "test case %d failed: bad import meta", i) + require.Equal(t, tc.expected.sourceMeta, pkg.GoSourceMeta(), "test case %d failed: bad source meta", i) + } + }) +}