Skip to content

Commit

Permalink
Add -base-path flag (#762)
Browse files Browse the repository at this point in the history
This allows self-hosting GoatCounter and reverse proxying it into a
subdirectory instead of hosting it on its own subdomain.

Fixes #707, #750

Co-authored-by: Martin Tournoij <martin@arp242.net>
  • Loading branch information
mk12 and arp242 authored Aug 19, 2024
1 parent c6e8967 commit f413299
Show file tree
Hide file tree
Showing 73 changed files with 283 additions and 204 deletions.
4 changes: 2 additions & 2 deletions cmd/goatcounter/saas.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ func cmdSaas(f zli.Flags, ready chan<- struct{}, stop chan struct{}) error {
hosts := map[string]http.Handler{
d: zhttp.RedirectHost("//www." + domain),
"www." + d: handlers.NewWebsite(db, dev),
"*": handlers.NewBackend(db, acmeh, dev, c.GoatcounterCom, websocket, c.DomainStatic, 15, apiMax),
"*": handlers.NewBackend(db, acmeh, dev, c.GoatcounterCom, websocket, c.DomainStatic, c.BasePath, 15, apiMax),
}
if dev {
hosts[znet.RemovePort(domainStatic)] = handlers.NewStatic(chi.NewRouter(), dev, true)
hosts[znet.RemovePort(domainStatic)] = handlers.NewStatic(chi.NewRouter(), dev, true, c.BasePath)
}

return doServe(ctx, db, listen, listenTLS, tlsc, hosts, stop, func() {
Expand Down
24 changes: 20 additions & 4 deletions cmd/goatcounter/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ Flags:
-public-port Port your site is publicly accessible on. Only needed if it's
not 80 or 443.
-base-path Path under which GoatCounter is available. Usually GoatCounter
runs on its own domain or subdomain ("stats.example.com"), but in
some cases it's useful to run GoatCounter under a path
("example.com/stats"), in which case you'll need to set this to
"/stats".
-automigrate Automatically run all pending migrations on startup.
-smtp SMTP relay server, as URL (e.g. "smtp://user:pass@server").
Expand Down Expand Up @@ -164,18 +170,25 @@ func cmdServe(f zli.Flags, ready chan<- struct{}, stop chan struct{}) error {
var (
// TODO(depr): -port is for compat with <2.0
port = f.Int(0, "public-port", "port").Pointer()
basePath = f.String("/", "base-path").Pointer()
domainStatic = f.String("", "static").Pointer()
)
dbConnect, dbConn, dev, automigrate, listen, flagTLS, from, websocket, apiMax, err := flagsServe(f, &v)
if err != nil {
return err
}

return func(port int, domainStatic string) error {
return func(port int, basePath, domainStatic string) error {
if flagTLS == "" {
flagTLS = map[bool]string{true: "http", false: "acme,rdr"}[dev]
}

basePath = strings.Trim(basePath, "/")
if basePath != "" {
basePath = "/" + basePath
}
zhttp.BasePath = basePath

var domainCount, urlStatic string
if domainStatic != "" {
if p := strings.Index(domainStatic, ":"); p > -1 {
Expand All @@ -185,6 +198,8 @@ func cmdServe(f zli.Flags, ready chan<- struct{}, stop chan struct{}) error {
}
urlStatic = "//" + domainStatic
domainCount = domainStatic
} else {
urlStatic = basePath
}

//from := flagFrom(from, "cfg.Domain", &v)
Expand All @@ -206,17 +221,18 @@ func cmdServe(f zli.Flags, ready chan<- struct{}, stop chan struct{}) error {
c.DomainStatic = domainStatic
c.Dev = dev
c.URLStatic = urlStatic
c.BasePath = basePath
c.DomainCount = domainCount
c.Websocket = websocket

// Set up HTTP handler and servers.
hosts := map[string]http.Handler{
"*": handlers.NewBackend(db, acmeh, dev, c.GoatcounterCom, websocket, c.DomainStatic, 60, apiMax),
"*": handlers.NewBackend(db, acmeh, dev, c.GoatcounterCom, websocket, c.DomainStatic, c.BasePath, 60, apiMax),
}
if domainStatic != "" {
// May not be needed, but just in case the DomainStatic isn't an
// external CDN.
hosts[znet.RemovePort(domainStatic)] = handlers.NewStatic(chi.NewRouter(), dev, false)
hosts[znet.RemovePort(domainStatic)] = handlers.NewStatic(chi.NewRouter(), dev, false, c.BasePath)
}

cnames, err := lsSites(ctx)
Expand All @@ -238,7 +254,7 @@ func cmdServe(f zli.Flags, ready chan<- struct{}, stop chan struct{}) error {
}
ready <- struct{}{}
})
}(*port, *domainStatic)
}(*port, *basePath, *domainStatic)
}

func doServe(ctx context.Context, db zdb.DB,
Expand Down
1 change: 1 addition & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type GlobalConfig struct {
Domain string
DomainStatic string
DomainCount string
BasePath string
URLStatic string
Dev bool
GoatcounterCom bool
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ require (
zgo.at/zcache v1.2.0
zgo.at/zcache/v2 v2.1.0
zgo.at/zdb v0.0.0-20240818155550-1a862f98cab0
zgo.at/zhttp v0.0.0-20240812113805-6333261ded60
zgo.at/zhttp v0.0.0-20240819012318-b761c83c740e
zgo.at/zli v0.0.0-20240614180544-47534b1ce136
zgo.at/zlog v0.0.0-20211017235224-dd4772ddf860
zgo.at/zprof v0.0.0-20211217104121-c3c12596d8f0
Expand Down
4 changes: 2 additions & 2 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 13 additions & 4 deletions handlers/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,28 @@ import (
"zgo.at/zstd/zfs"
)

func NewBackend(db zdb.DB, acmeh http.HandlerFunc, dev, goatcounterCom, websocket bool, domainStatic string, dashTimeout, apiMax int) chi.Router {
r := chi.NewRouter()
func NewBackend(db zdb.DB, acmeh http.HandlerFunc, dev, goatcounterCom, websocket bool,
domainStatic string, basePath string, dashTimeout, apiMax int,
) chi.Router {

root := chi.NewRouter()
r := root
if basePath != "" {
r = chi.NewRouter()
root.Mount(basePath, r)
}

backend{dashTimeout, websocket}.Mount(r, db, dev, domainStatic, dashTimeout, apiMax)

if acmeh != nil {
r.Get("/.well-known/acme-challenge/{key}", acmeh)
}

if !goatcounterCom {
NewStatic(r, dev, goatcounterCom)
NewStatic(r, dev, goatcounterCom, basePath)
}

return r
return root
}

type backend struct {
Expand Down
2 changes: 1 addition & 1 deletion handlers/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,5 +249,5 @@ func BenchmarkCount(b *testing.B) {
}

func newBackend(db zdb.DB) chi.Router {
return NewBackend(db, nil, true, true, false, "example.com", 10, 0)
return NewBackend(db, nil, true, true, false, "example.com", "", 10, 0)
}
5 changes: 1 addition & 4 deletions handlers/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,7 @@ func (h backend) dashboard(w http.ResponseWriter, r *http.Request) error {

cd := goatcounter.Config(r.Context()).DomainCount
if cd == "" {
cd = Site(r.Context()).Domain(r.Context())
if goatcounter.Config(r.Context()).Port != "" {
cd += ":" + goatcounter.Config(r.Context()).Port
}
cd = Site(r.Context()).SchemelessURL(r.Context())
}

args := widgets.Args{
Expand Down
16 changes: 13 additions & 3 deletions handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type Globals struct {
User *goatcounter.User
Site *goatcounter.Site
Path string
Base string
Flash *zhttp.FlashMessage
Static string
StaticDomain string
Expand All @@ -79,11 +80,17 @@ func (g Globals) T(msg string, data ...any) template.HTML {

func newGlobals(w http.ResponseWriter, r *http.Request) Globals {
ctx := r.Context()
base := goatcounter.Config(ctx).BasePath
path := strings.TrimPrefix(r.URL.Path, base)
if path == "" {
path = "/"
}
g := Globals{
Context: ctx,
User: goatcounter.GetUser(ctx),
Site: goatcounter.GetSite(ctx),
Path: r.URL.Path,
Path: path,
Base: base,
Flash: zhttp.ReadFlash(w, r),
Static: goatcounter.Config(ctx).URLStatic,
Domain: goatcounter.Config(ctx).Domain,
Expand Down Expand Up @@ -124,7 +131,7 @@ func newGlobals(w http.ResponseWriter, r *http.Request) Globals {
return g
}

func NewStatic(r chi.Router, dev, goatcounterCom bool) chi.Router {
func NewStatic(r chi.Router, dev, goatcounterCom bool, basePath string) chi.Router {
var cache map[string]int
if !dev {
cache = map[string]int{
Expand All @@ -141,6 +148,9 @@ func NewStatic(r chi.Router, dev, goatcounterCom bool) chi.Router {
s.Header("/count.js", map[string]string{
"Cross-Origin-Resource-Policy": "cross-origin",
})
r.Get("/*", s.ServeHTTP)
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, basePath)
s.ServeHTTP(w, r)
})
return r
}
2 changes: 1 addition & 1 deletion handlers/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func (h i18n) show(w http.ResponseWriter, r *http.Request) error {

return zhttp.Template(w, "i18n_show.gohtml", struct {
Globals
Base msgfile.File
BaseFile msgfile.File
File msgfile.File
TOMLFile string
FormatLink func(string) string
Expand Down
9 changes: 5 additions & 4 deletions handlers/mw.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ var Started time.Time
var (
redirect = func(w http.ResponseWriter, r *http.Request) error {
zhttp.Flash(w, "Need to log in")
return guru.Errorf(303, "/user/new")
return guru.Errorf(303, goatcounter.Config(r.Context()).BasePath+"/user/new")
}

loggedIn = auth.Filter(func(w http.ResponseWriter, r *http.Request) error {
Expand Down Expand Up @@ -76,7 +76,7 @@ var (
Secure: zhttp.CookieSecure,
SameSite: zhttp.CookieSameSite,
})
return guru.Errorf(303, "/")
return guru.Errorf(303, goatcounter.Config(r.Context()).BasePath+"/")
}
if c, err := r.Cookie("access-token"); err == nil && s.Settings.CanView(c.Value) {
return nil
Expand Down Expand Up @@ -220,7 +220,7 @@ func addctx(db zdb.DB, loadSite bool, dashTimeout int) func(http.Handler) http.H

func noSites(db zdb.DB, w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
w.Header().Set("Location", "/")
w.Header().Set("Location", goatcounter.Config(r.Context()).BasePath+"/")
w.WriteHeader(307)
return
}
Expand Down Expand Up @@ -302,11 +302,12 @@ func noSites(db zdb.DB, w http.ResponseWriter, r *http.Request) {
}

err := zhttp.Template(w, "serve_newsite.gohtml", struct {
Globals
Validate *zvalidate.Validator
Error error
Email string
Cname string
}{&v, tplErr, args.Email, args.Cname})
}{newGlobals(w, r), &v, tplErr, args.Email, args.Cname})
if err != nil {
zlog.Error(err)
}
Expand Down
2 changes: 1 addition & 1 deletion handlers/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ func (h user) verify(w http.ResponseWriter, r *http.Request) error {
return zhttp.SeeOther(w, "/")
}

// Make sure to use the currect cookie, since both "custom.example.com" and
// Make sure to use the correct cookie, since both "custom.example.com" and
// "example.goatcounter.com" will work if you're using a custom domain.
func cookieDomain(site *goatcounter.Site, r *http.Request) string {
if r.Host == site.Domain(r.Context()) {
Expand Down
8 changes: 2 additions & 6 deletions handlers/website.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ func (h website) doSignup(w http.ResponseWriter, r *http.Request) error {
}
})

return zhttp.SeeOther(w, fmt.Sprintf("%s/user/new", site.URL(r.Context())))
return zhttp.SeeOther(w, site.URL(r.Context())+"/user/new")
}

func (h website) forgot(err error, email, turingTest string) zhttp.HandlerFunc {
Expand Down Expand Up @@ -481,11 +481,7 @@ func (h website) help(w http.ResponseWriter, r *http.Request) error {

dc := goatcounter.Config(r.Context()).DomainCount
if dc == "" {
dc = Site(r.Context()).Domain(r.Context())
port := goatcounter.Config(r.Context()).Port
if port != "" {
dc += port
}
dc = Site(r.Context()).SchemelessURL(r.Context())
}

cp := chi.URLParam(r, "*")
Expand Down
13 changes: 7 additions & 6 deletions public/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
$(document).ready(function() {
window.I18N = JSON.parse($('#js-i18n').text())
window.USER_SETTINGS = JSON.parse($('#js-settings').text())
window.BASE_PATH = $('#js-settings').attr('data-base-path') || ""
window.CSRF = $('#js-settings').attr('data-csrf')
window.TZ_OFFSET = parseInt($('#js-settings').attr('data-offset'), 10) || 0
window.SITE_FIRST_HIT_AT = $('#js-settings').attr('data-first-hit-at') * 1000
Expand All @@ -25,9 +26,9 @@
var report_errors = function() {
window.onerror = on_error
$(document).on('ajaxError', function(e, xhr, settings, err) {
if (settings.url === '/jserr') // Just in case, otherwise we'll be stuck.
if (settings.url === BASE_PATH + '/jserr') // Just in case, otherwise we'll be stuck.
return
if (settings.url === '/load-widget')
if (settings.url === BASE_PATH + '/load-widget')
return
var msg = T("error/load-url", {url: settings.url, error: err})
console.error(msg)
Expand All @@ -53,7 +54,7 @@
return

jQuery.ajax({
url: '/jserr',
url: BASE_PATH + '/jserr',
method: 'POST',
data: {msg: msg, url: url, line: line, column: column, stack: (err||{}).stack, ua: navigator.userAgent, loc: window.location+''},
})
Expand Down Expand Up @@ -125,7 +126,7 @@
e.preventDefault()

jQuery.ajax({
url: '/settings/main/ip',
url: BASE_PATH + '/settings/main/ip',
success: function(data) {
var input = $('[name="settings.ignore_ips"]'),
current = input.val().split(',').
Expand Down Expand Up @@ -161,7 +162,7 @@

// Update redirect link.
$('#settings-secret').on('change', function(e) {
$('#secret-url').val(`${location.protocol}//${location.host}?access-token=${this.value}`)
$('#secret-url').val(`${location.protocol}//${location.host}${BASE_PATH}?access-token=${this.value}`)
}).trigger('change')
}

Expand Down Expand Up @@ -193,7 +194,7 @@
return

jQuery.ajax({
url: '/user/dashboard/widget/' + this.selectedOptions[0].value,
url: BASE_PATH + '/user/dashboard/widget/' + this.selectedOptions[0].value,
success: function(data) {
var i = 1 + $('.index').toArray().map((e) => parseInt(e.value, 10)).sort().pop(),
html = $(data.replace(/widgets([\[_])0([\]_])/g, `widgets$1${i}$2`))
Expand Down
Loading

0 comments on commit f413299

Please sign in to comment.