Skip to content

Commit ceba08c

Browse files
authored
Vanity URL Handler (#3)
1 parent d5c35e8 commit ceba08c

File tree

9 files changed

+307
-12
lines changed

9 files changed

+307
-12
lines changed

config/config.go

+10-8
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@ const Prefix = "vanity"
1515

1616
// Configures the vanityd server from the environment.
1717
type Config struct {
18-
Maintenance bool `default:"false" desc:"if true, the server will start in maintenance mode"`
19-
LogLevel logger.LevelDecoder `split_words:"true" default:"info" desc:"specify the verbosity of logging (trace, debug, info, warn, error, fatal panic)"`
20-
ConsoleLog bool `split_words:"true" default:"false" desc:"if true logs colorized human readable output instead of json"`
21-
BindAddr string `split_words:"true" default:":3264" desc:"the ip address and port to bind the server on"`
22-
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"`
23-
WriteTimeout time.Duration `split_words:"true" default:"20s" desc:"maximum amount of time before timing out a write to a response"`
24-
IdleTimeout time.Duration `split_words:"true" default:"10m" desc:"maximum amount of time to wait for the next request while keep alives are enabled"`
25-
processed bool
18+
Maintenance bool `default:"false" desc:"if true, the server will start in maintenance mode"`
19+
Domain string `default:"" desc:"specify the domain of the vanity URLs, otherwise the request host will be used"`
20+
DefaultBranch string `split_words:"true" default:"main" desc:"specify the default branch to use for repository references"`
21+
LogLevel logger.LevelDecoder `split_words:"true" default:"info" desc:"specify the verbosity of logging (trace, debug, info, warn, error, fatal panic)"`
22+
ConsoleLog bool `split_words:"true" default:"false" desc:"if true logs colorized human readable output instead of json"`
23+
BindAddr string `split_words:"true" default:":3264" desc:"the ip address and port to bind the server on"`
24+
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"`
25+
WriteTimeout time.Duration `split_words:"true" default:"20s" desc:"maximum amount of time before timing out a write to a response"`
26+
IdleTimeout time.Duration `split_words:"true" default:"10m" desc:"maximum amount of time to wait for the next request while keep alives are enabled"`
27+
processed bool
2628
}
2729

2830
func New() (conf Config, err error) {

errors.go

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package vanity
2+
3+
import "errors"
4+
5+
var (
6+
ErrNoRepository = errors.New("a repository url is required")
7+
ErrInvalidRepository = errors.New("expected repository url in the form vcsScheme://vcsHost/user/repo")
8+
ErrInvalidProtocol = errors.New("protocol must be git, github, or gogs")
9+
)

logger/middleware.go

+1-3
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@ import (
55
"time"
66

77
"github.com/julienschmidt/httprouter"
8-
"github.com/rotationalio/vanity"
98
"github.com/rotationalio/vanity/server/middleware"
109
"github.com/rs/zerolog/log"
1110
)
1211

13-
func HTTPLogger(server string) middleware.Middleware {
14-
version := vanity.Version()
12+
func HTTPLogger(server, version string) middleware.Middleware {
1513
return func(next httprouter.Handle) httprouter.Handle {
1614
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
1715
// Before the request

server/routes.go

+6-1
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ import (
55
"net/http"
66

77
"github.com/julienschmidt/httprouter"
8+
"github.com/rotationalio/vanity"
89
"github.com/rotationalio/vanity/logger"
910
"github.com/rotationalio/vanity/server/middleware"
1011
)
1112

1213
// Sets up the server's middleware and routes.
1314
func (s *Server) setupRoutes() (err error) {
1415
middleware := []middleware.Middleware{
15-
logger.HTTPLogger("vanity"),
16+
logger.HTTPLogger("vanity", vanity.Version()),
1617
s.Maintenance(),
1718
}
1819

@@ -31,6 +32,10 @@ func (s *Server) setupRoutes() (err error) {
3132
s.addRoute(http.MethodGet, "/", s.HomePage(), middleware...)
3233

3334
// Golang Vanity Handling
35+
pkg := &vanity.GoPackage{Domain: "go.rotational.io", Repository: "https://github.com/rotationalio/confire"}
36+
pkg.Resolve(nil)
37+
s.addRoute(http.MethodGet, "/rotationalio/confire", Vanity(pkg), middleware...)
38+
s.addRoute(http.MethodGet, "/rotationalio/confire/*filepath", Vanity(pkg), middleware...)
3439

3540
return nil
3641
}

server/static/css/style.css

+4
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@ header div {
3434
padding: 24px;
3535
margin: 0;
3636
font-size: 24px;
37+
}
38+
39+
.redirect {
40+
padding: 32px 0;
3741
}

server/templates/vanity.html

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
5+
<meta name="go-import" content="{{ .GoImportMeta }}" />
6+
<meta name="go-source" content="{{ .GoSourceMeta }}" />
7+
<meta http-equiv="refresh" content="0; url={{ .Redirect }}">
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
9+
<meta name="robots" content="noindex,follow">
10+
11+
<title>Rotational Go Packages</title>
12+
13+
<link rel="icon" type="image/ico" href="/static/img/favicon.ico">
14+
<link rel="stylesheet" href="/static/css/style.css" />
15+
</head>
16+
<body>
17+
<header >
18+
<div class="redirect text-center">
19+
If not automatically redirected please click: <a href="{{ .Redirect }}">{{ .Redirect }}</a>.
20+
</div>
21+
</header>
22+
</body>
23+
</html>

server/vanity.go

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package server
2+
3+
import (
4+
"html/template"
5+
"io/fs"
6+
"net/http"
7+
8+
"github.com/julienschmidt/httprouter"
9+
"github.com/rotationalio/vanity"
10+
)
11+
12+
func Vanity(pkg *vanity.GoPackage) httprouter.Handle {
13+
// Compile the template for serving the vanity url.
14+
templates, _ := fs.Sub(content, "templates")
15+
index := template.Must(template.ParseFS(templates, "*.html"))
16+
17+
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
18+
// Construct a request specific response
19+
data := pkg.WithRequest(r)
20+
21+
// Issue an HTTP redirect if this is definitely a browser.
22+
if r.FormValue("go-get") != "1" {
23+
http.Redirect(w, r, data.Redirect(), http.StatusTemporaryRedirect)
24+
return
25+
}
26+
27+
// Write go-import and go-source meta tags to response.
28+
w.Header().Set("Cache-Control", "public, max-age=300")
29+
if err := index.ExecuteTemplate(w, "vanity.html", data); err != nil {
30+
http.Error(w, err.Error(), http.StatusInternalServerError)
31+
}
32+
}
33+
}

vanity.go

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package vanity
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/rotationalio/vanity/config"
10+
)
11+
12+
const (
13+
protocolGit = "git"
14+
protocolGitHub = "github"
15+
protocolGOGS = "gogs"
16+
)
17+
18+
var godoc *url.URL = &url.URL{
19+
Scheme: "https",
20+
Host: "godoc.org",
21+
}
22+
23+
var validProtocols = map[string]struct{}{
24+
protocolGit: {},
25+
protocolGitHub: {},
26+
protocolGOGS: {},
27+
}
28+
29+
type GoPackage struct {
30+
Domain string `json:"-"` // the vanity URL domain to use
31+
Module string `json:"-"` // the module name where go.mod is located; parsed from the repository
32+
Package string `json:"-"` // the full package path being requested for correct redirects
33+
Protocol string `json:"protocol"` // can be "git", "github", or "gogs" -- defaults to "git"
34+
Repository string `json:"repository"` // a path to the public repository starting with https://
35+
Branch string `json:"branch"` // the name of the default branch -- defaults to "main"
36+
repo *url.URL `json:"-"` // the parsed repository URL
37+
user string `json:"-"` // the user or organization from the repository
38+
}
39+
40+
func (p *GoPackage) Resolve(conf *config.Config) (err error) {
41+
// Verify there is a repository
42+
if p.Repository == "" {
43+
return ErrNoRepository
44+
}
45+
46+
// Parse the repository
47+
if p.repo, err = url.Parse(p.Repository); err != nil {
48+
return ErrInvalidRepository
49+
}
50+
51+
parts := strings.Split(p.repo.Path, "/")
52+
if len(parts) != 3 {
53+
return ErrInvalidRepository
54+
}
55+
56+
p.user = parts[1]
57+
p.Module = parts[2]
58+
59+
// Check protocol
60+
if p.Protocol == "" {
61+
p.Protocol = protocolGit
62+
}
63+
64+
if _, ok := validProtocols[p.Protocol]; !ok {
65+
return ErrInvalidProtocol
66+
}
67+
68+
// Manage the configuration
69+
if conf != nil {
70+
p.Domain = conf.Domain
71+
72+
if p.Branch == "" {
73+
p.Branch = conf.DefaultBranch
74+
}
75+
}
76+
77+
// Check the ref
78+
if p.Branch == "" {
79+
p.Branch = "main"
80+
}
81+
82+
return nil
83+
}
84+
85+
func (p *GoPackage) WithRequest(r *http.Request) GoPackage {
86+
pkg := p.Module
87+
if r != nil {
88+
pkg = r.URL.Path
89+
}
90+
91+
clone := GoPackage{
92+
Domain: p.Domain,
93+
Module: p.Module,
94+
Package: pkg,
95+
Protocol: p.Protocol,
96+
Repository: p.Repository,
97+
Branch: p.Branch,
98+
repo: p.repo,
99+
user: p.user,
100+
}
101+
102+
if clone.Domain == "" && r != nil {
103+
clone.Domain = r.Host
104+
}
105+
106+
return clone
107+
}
108+
109+
func (p GoPackage) Redirect() string {
110+
return godoc.ResolveReference(
111+
&url.URL{
112+
Path: filepath.Join("/", p.Domain, p.Package),
113+
},
114+
).String()
115+
}
116+
117+
func (p GoPackage) GoImportMeta() string {
118+
parts := []string{
119+
p.Import(),
120+
p.Protocol,
121+
p.repo.String(),
122+
}
123+
124+
return strings.Join(parts, " ")
125+
}
126+
127+
func (p GoPackage) GoSourceMeta() string {
128+
parts := []string{
129+
p.Import(),
130+
p.repo.String(),
131+
"",
132+
"",
133+
}
134+
parts[2], parts[3] = p.Source()
135+
return strings.Join(parts, " ")
136+
}
137+
138+
func (p GoPackage) Import() string {
139+
return filepath.Join(p.Domain, p.Module)
140+
}
141+
142+
func (p GoPackage) Source() (string, string) {
143+
switch p.Protocol {
144+
case protocolGit, protocolGitHub:
145+
return p.githubSource()
146+
case protocolGOGS:
147+
return p.gogsSource()
148+
default:
149+
return "", ""
150+
}
151+
}
152+
153+
func (p GoPackage) githubSource() (string, string) {
154+
base := filepath.Join(p.user, p.Module)
155+
directoryPath := filepath.Join(base, "tree", p.Branch+"{/dir}")
156+
filePath := filepath.Join(base, "blob", p.Branch+"{/dir}", "{file}#L{line}")
157+
158+
uri := p.repo.ResolveReference(&url.URL{Path: "/"}).String()
159+
return uri + directoryPath, uri + filePath
160+
}
161+
162+
func (p GoPackage) gogsSource() (string, string) {
163+
base := filepath.Join(p.user, p.Module)
164+
directoryPath := filepath.Join(base, "src", p.Branch+"{/dir}")
165+
filePath := filepath.Join(base, "src", p.Branch+"{/dir}", "{file}#L{line}")
166+
167+
uri := p.repo.ResolveReference(&url.URL{Path: "/"}).String()
168+
return uri + directoryPath, uri + filePath
169+
}

vanity_test.go

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package vanity_test
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"testing"
7+
8+
. "github.com/rotationalio/vanity"
9+
"github.com/rotationalio/vanity/config"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
type expected struct {
14+
redirect string
15+
importMeta string
16+
sourceMeta string
17+
}
18+
19+
func TestGoPackage(t *testing.T) {
20+
t.Run("Valid", func(t *testing.T) {
21+
testCases := []struct {
22+
in *GoPackage
23+
conf *config.Config
24+
req *http.Request
25+
expected expected
26+
}{
27+
{
28+
in: &GoPackage{Repository: "https://github.com/rotationalio/confire"},
29+
conf: &config.Config{Domain: "go.rotational.io", DefaultBranch: "main"},
30+
req: &http.Request{URL: &url.URL{Path: "/confire/validate"}},
31+
expected: expected{
32+
redirect: "https://godoc.org/go.rotational.io/confire/validate",
33+
importMeta: "go.rotational.io/confire git https://github.com/rotationalio/confire",
34+
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}",
35+
},
36+
},
37+
}
38+
39+
for i, tc := range testCases {
40+
// Resolve the go package data
41+
require.NoError(t, tc.in.Resolve(tc.conf), "test case %d failed: could not resolve", i)
42+
43+
// Finalize package for the request
44+
pkg := tc.in.WithRequest(tc.req)
45+
46+
// Perform assertions
47+
require.Equal(t, tc.expected.redirect, pkg.Redirect(), "test case %d failed: bad redirect", i)
48+
require.Equal(t, tc.expected.importMeta, pkg.GoImportMeta(), "test case %d failed: bad import meta", i)
49+
require.Equal(t, tc.expected.sourceMeta, pkg.GoSourceMeta(), "test case %d failed: bad source meta", i)
50+
}
51+
})
52+
}

0 commit comments

Comments
 (0)