From b9ed19f337a61fe3d767fa338270187929f2a978 Mon Sep 17 00:00:00 2001 From: Mitchell Kember Date: Sat, 17 Aug 2024 23:55:13 -0700 Subject: [PATCH 01/16] Add -base-path flag This allows self-hosting GoatCounter and reverse proxying it into a subdirectory instead of hosting it on its own subdomain. Fixes #707, #750 --- cmd/goatcounter/serve.go | 20 +++++++- context.go | 1 + handlers/bosmang.go | 4 +- handlers/dashboard.go | 5 +- handlers/handlers.go | 2 + handlers/i18n.go | 8 +-- handlers/mw.go | 11 +++-- handlers/redirect.go | 27 ++++++++++ handlers/settings.go | 38 +++++++------- handlers/settings_user.go | 8 +-- handlers/user.go | 68 +++++++++++++------------- handlers/website.go | 28 +++++------ public/backend.js | 11 +++-- public/dashboard.js | 20 ++++---- public/help.js | 6 ++- settings.go | 2 +- site.go | 21 +++++--- tpl/_backend_bottom.gohtml | 1 + tpl/_backend_signin.gohtml | 4 +- tpl/_backend_top.gohtml | 22 ++++----- tpl/_bottom_links.gohtml | 6 +-- tpl/_contact.gohtml | 2 +- tpl/_dashboard_configure_widget.gohtml | 2 +- tpl/_dashboard_hchart.gohtml | 2 +- tpl/_dashboard_warn_collect.gohtml | 2 +- tpl/_settings_nav.gohtml | 14 +++--- tpl/_top.gohtml | 4 +- tpl/_user_nav.gohtml | 8 +-- tpl/bosmang_bgrun.gohtml | 2 +- tpl/contact.gohtml | 2 +- tpl/dashboard.gohtml | 8 +-- tpl/error.gohtml | 1 + tpl/help.gohtml | 4 +- tpl/home.gohtml | 16 +++--- tpl/i18n_list.gohtml | 6 +-- tpl/i18n_show.gohtml | 4 +- tpl/serve_newsite.gohtml | 2 +- tpl/settings_delete.gohtml | 2 +- tpl/settings_export.gohtml | 8 +-- tpl/settings_main.gohtml | 6 +-- tpl/settings_purge.gohtml | 6 +-- tpl/settings_server.gohtml | 12 ++--- tpl/settings_sites.gohtml | 6 +-- tpl/settings_users.gohtml | 6 +-- tpl/signup.gohtml | 2 +- tpl/totp.gohtml | 2 +- tpl/user_api.gohtml | 10 ++-- tpl/user_auth.gohtml | 6 +-- tpl/user_dashboard.gohtml | 2 +- tpl/user_forgot_code.gohtml | 2 +- tpl/user_forgot_pw.gohtml | 2 +- tpl/user_pref.gohtml | 4 +- tpl/user_reset.gohtml | 2 +- widgets/browsers.go | 3 +- widgets/campaigns.go | 3 +- widgets/languages.go | 3 +- widgets/locations.go | 3 +- widgets/sizes.go | 3 +- widgets/systems.go | 3 +- 59 files changed, 272 insertions(+), 216 deletions(-) create mode 100644 handlers/redirect.go diff --git a/cmd/goatcounter/serve.go b/cmd/goatcounter/serve.go index eaa2dd1ee..e828ed558 100644 --- a/cmd/goatcounter/serve.go +++ b/cmd/goatcounter/serve.go @@ -81,6 +81,11 @@ Flags: -public-port Port your site is publicly accessible on. Only needed if it's not 80 or 443. + -base-path Base path to use in URLs sent back to the client. Default: "/". + You need this if you are reverse proxying GoatCounter into a + subdirectory of your website. This does not affect routing, since + it is assumed the reverse proxy will strip off the base path. + -automigrate Automatically run all pending migrations on startup. -smtp SMTP relay server, as URL (e.g. "smtp://user:pass@server"). @@ -164,6 +169,7 @@ 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) @@ -171,11 +177,18 @@ func cmdServe(f zli.Flags, ready chan<- struct{}, stop chan struct{}) error { 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] } + if !strings.HasPrefix(basePath, "/") { + return fmt.Errorf("invalid -base-path flag: %q: must start with a slash", basePath) + } + if i := len(basePath) - 1; basePath[i] == '/' { + basePath = basePath[:i] + } + var domainCount, urlStatic string if domainStatic != "" { if p := strings.Index(domainStatic, ":"); p > -1 { @@ -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) @@ -206,6 +221,7 @@ 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 @@ -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, diff --git a/context.go b/context.go index ec99117c9..6302f299e 100644 --- a/context.go +++ b/context.go @@ -42,6 +42,7 @@ type GlobalConfig struct { Domain string DomainStatic string DomainCount string + BasePath string URLStatic string Dev bool GoatcounterCom bool diff --git a/handlers/bosmang.go b/handlers/bosmang.go index 36930f0e0..8263f7928 100644 --- a/handlers/bosmang.go +++ b/handlers/bosmang.go @@ -32,7 +32,7 @@ func (h bosmang) mount(r chi.Router, db zdb.DB) { a := r.With(mware.RequestLog(nil), requireAccess(goatcounter.AccessSuperuser)) r.Get("/bosmang", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { - return zhttp.MovedPermanently(w, "/settings/server") + return MovedPermanently(w, r, "/settings/server") })) a.Get("/bosmang/cache", zhttp.Wrap(h.cache)) @@ -98,7 +98,7 @@ func (h bosmang) runTask(w http.ResponseWriter, r *http.Request) error { }) zhttp.Flash(w, "Task %q started", id) - return zhttp.SeeOther(w, "/bosmang/bgrun") + return SeeOther(w, r, "/bosmang/bgrun") } func (h bosmang) metrics(w http.ResponseWriter, r *http.Request) error { diff --git a/handlers/dashboard.go b/handlers/dashboard.go index 956c0f5ec..950e3ad11 100644 --- a/handlers/dashboard.go +++ b/handlers/dashboard.go @@ -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{ diff --git a/handlers/handlers.go b/handlers/handlers.go index fb9ee4ac0..4756e1677 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -60,6 +60,7 @@ type Globals struct { User *goatcounter.User Site *goatcounter.Site Path string + Base string Flash *zhttp.FlashMessage Static string StaticDomain string @@ -84,6 +85,7 @@ func newGlobals(w http.ResponseWriter, r *http.Request) Globals { User: goatcounter.GetUser(ctx), Site: goatcounter.GetSite(ctx), Path: r.URL.Path, + Base: goatcounter.Config(ctx).BasePath, Flash: zhttp.ReadFlash(w, r), Static: goatcounter.Config(ctx).URLStatic, Domain: goatcounter.Config(ctx).Domain, diff --git a/handlers/i18n.go b/handlers/i18n.go index 5cd48d1ef..3c097f89b 100644 --- a/handlers/i18n.go +++ b/handlers/i18n.go @@ -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 @@ -187,7 +187,7 @@ func (h i18n) new(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, "%q added", args.Language) - return zhttp.SeeOther(w, "/i18n") + return SeeOther(w, r, "/i18n") } func (h i18n) save(w http.ResponseWriter, r *http.Request) error { @@ -266,7 +266,7 @@ func (h i18n) set(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, "language set to %q", lang) - return zhttp.SeeOther(w, "/i18n") + return SeeOther(w, r, "/i18n") } func (h i18n) submit(w http.ResponseWriter, r *http.Request) error { @@ -294,5 +294,5 @@ func (h i18n) submit(w http.ResponseWriter, r *http.Request) error { }() zhttp.Flash(w, "email sent to support@goatcounter.com; I'll take a look as soon as possible.") - return zhttp.SeeOther(w, "/i18n/"+file) + return SeeOther(w, r, "/i18n/"+file) } diff --git a/handlers/mw.go b/handlers/mw.go index e8d4e822a..a855f7022 100644 --- a/handlers/mw.go +++ b/handlers/mw.go @@ -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 { @@ -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 @@ -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 } @@ -290,7 +290,7 @@ func noSites(db zdb.DB, w http.ResponseWriter, r *http.Request) { tplErr = errors.Unwrap(tplErr) // Remove "zdb.TX fn: " } if tplErr == nil && !v.HasErrors() { - zhttp.SeeOther(w, "/") + SeeOther(w, r, "/") } } @@ -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) } diff --git a/handlers/redirect.go b/handlers/redirect.go new file mode 100644 index 000000000..229a43b54 --- /dev/null +++ b/handlers/redirect.go @@ -0,0 +1,27 @@ +// Copyright © Martin Tournoij – This file is part of GoatCounter and published +// under the terms of a slightly modified EUPL v1.2 license, which can be found +// in the LICENSE file or at https://license.goatcounter.com + +package handlers + +import ( + "fmt" + "net/http" + + "zgo.at/goatcounter/v2" + "zgo.at/zhttp" +) + +func MovedPermanently(w http.ResponseWriter, r *http.Request, path string) error { + if path == "" || path[0] != '/' { + panic(fmt.Sprintf("handlers.MovedPermantly: %q does not start with slash", path)) + } + return zhttp.MovedPermanently(w, goatcounter.Config(r.Context()).BasePath+path) +} + +func SeeOther(w http.ResponseWriter, r *http.Request, path string) error { + if path == "" || path[0] != '/' { + panic(fmt.Sprintf("handlers.SeeOther: %q does not start with slash", path)) + } + return zhttp.SeeOther(w, goatcounter.Config(r.Context()).BasePath+path) +} diff --git a/handlers/settings.go b/handlers/settings.go index 5737b54c3..a82a995d2 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -42,7 +42,7 @@ type settings struct{} func (h settings) mount(r chi.Router) { { // User settings. r.Get("/user", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - zhttp.SeeOther(w, "/user/pref") + SeeOther(w, r, "/user/pref") })) r.Get("/user/pref", zhttp.Wrap(h.userPref(nil))) @@ -63,7 +63,7 @@ func (h settings) mount(r chi.Router) { set := r.With(requireAccess(goatcounter.AccessSettings)) set.Get("/settings", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - zhttp.SeeOther(w, "/settings/main") + SeeOther(w, r, "/settings/main") })) set.Get("/settings/main", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { return h.main(nil)(w, r) @@ -209,7 +209,7 @@ func (h settings) mainSave(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/saved|Saved!")) - return zhttp.SeeOther(w, "/settings") + return SeeOther(w, r, "/settings") } func (h settings) changeCode(w http.ResponseWriter, r *http.Request) error { @@ -299,7 +299,7 @@ func (h settings) sitesAdd(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/restored-previously-deleted-site|Site ‘%(url)’ was previously deleted; restored site with all data.", newSite.URL(r.Context()))) - return zhttp.SeeOther(w, "/settings/sites") + return SeeOther(w, r, "/settings/sites") } // Create new site. @@ -317,11 +317,11 @@ func (h settings) sitesAdd(w http.ResponseWriter, r *http.Request) error { }) if err != nil { zhttp.FlashError(w, err.Error()) - return zhttp.SeeOther(w, "/settings/sites") + return SeeOther(w, r, "/settings/sites") } zhttp.Flash(w, T(r.Context(), "notify/site-added|Site ‘%(url)’ added.", newSite.URL(r.Context()))) - return zhttp.SeeOther(w, "/settings/sites") + return SeeOther(w, r, "/settings/sites") } func (h settings) getSite(ctx context.Context, id int64) (*goatcounter.Site, error) { @@ -388,11 +388,11 @@ func (h settings) sitesRemove(w http.ResponseWriter, r *http.Request) error { err = parent.ByID(r.Context(), *s.Parent) if err != nil { zlog.Error(err) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } - return zhttp.SeeOther(w, parent.URL(r.Context())) + return SeeOther(w, r, parent.URL(r.Context())) } - return zhttp.SeeOther(w, "/settings/sites") + return SeeOther(w, r, "/settings/sites") } func (h settings) sitesCopySettings(w http.ResponseWriter, r *http.Request) error { @@ -436,7 +436,7 @@ func (h settings) sitesCopySettings(w http.ResponseWriter, r *http.Request) erro } zhttp.Flash(w, T(r.Context(), "notify/settings-copied-to-site|Settings copied to the selected sites.")) - return zhttp.SeeOther(w, "/settings/sites") + return SeeOther(w, r, "/settings/sites") } func (h settings) purge(w http.ResponseWriter, r *http.Request) error { @@ -486,7 +486,7 @@ func (h settings) purgeDo(w http.ResponseWriter, r *http.Request) error { }) zhttp.Flash(w, T(r.Context(), "notify/started-background-process|Started in the background; may take about 10-20 seconds to fully process.")) - return zhttp.SeeOther(w, "/settings/purge") + return SeeOther(w, r, "/settings/purge") } func (h settings) merge(w http.ResponseWriter, r *http.Request) error { @@ -512,7 +512,7 @@ func (h settings) merge(w http.ResponseWriter, r *http.Request) error { }) zhttp.Flash(w, T(r.Context(), "notify/started-background-process|Started in the background; may take about 10-20 seconds to fully process.")) - return zhttp.SeeOther(w, "/settings/purge") + return SeeOther(w, r, "/settings/purge") } func (h settings) export(verr *zvalidate.Validator) zhttp.HandlerFunc { @@ -548,7 +548,7 @@ func (h settings) exportDownload(w http.ResponseWriter, r *http.Request) error { if err != nil { if os.IsNotExist(err) { zhttp.FlashError(w, T(r.Context(), "error/export-expired|It looks like there is no export yet or the export has expired.")) - return zhttp.SeeOther(w, "/settings/export") + return SeeOther(w, r, "/settings/export") } return err @@ -633,7 +633,7 @@ func (h settings) exportImport(w http.ResponseWriter, r *http.Request) error { }) zhttp.Flash(w, T(r.Context(), "notify/import-started-in-background|Import started in the background; you’ll get an email when it’s done.")) - return zhttp.SeeOther(w, "/settings/export") + return SeeOther(w, r, "/settings/export") } func (h settings) exportStart(w http.ResponseWriter, r *http.Request) error { @@ -656,7 +656,7 @@ func (h settings) exportStart(w http.ResponseWriter, r *http.Request) error { func() { export.Run(ctx, fp, true) }) zhttp.Flash(w, T(r.Context(), "notify/export-started-in-background|Export started in the background; you’ll get an email with a download link when it’s done.")) - return zhttp.SeeOther(w, "/settings/export") + return SeeOther(w, r, "/settings/export") } func (h settings) delete(verr *zvalidate.Validator) zhttp.HandlerFunc { @@ -713,7 +713,7 @@ func (h settings) deleteDo(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - return zhttp.SeeOther(w, "https://"+goatcounter.Config(r.Context()).Domain) + return SeeOther(w, r, "https://"+goatcounter.Config(r.Context()).Domain) } func (h settings) users(verr *zvalidate.Validator) zhttp.HandlerFunc { @@ -829,7 +829,7 @@ func (h settings) usersAdd(w http.ResponseWriter, r *http.Request) error { }) zhttp.Flash(w, T(r.Context(), "notify/user-added|User ‘%(email)’ added.", newUser.Email)) - return zhttp.SeeOther(w, "/settings/users") + return SeeOther(w, r, "/settings/users") } func (h settings) usersEdit(w http.ResponseWriter, r *http.Request) error { @@ -887,7 +887,7 @@ func (h settings) usersEdit(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/users-edited|User ‘%(email)’ edited.", editUser.Email)) - return zhttp.SeeOther(w, "/settings/users") + return SeeOther(w, r, "/settings/users") } func (h settings) usersRemove(w http.ResponseWriter, r *http.Request) error { @@ -915,7 +915,7 @@ func (h settings) usersRemove(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/user-removed|User ‘%(email)’ removed.", user.Email)) - return zhttp.SeeOther(w, "/settings/users") + return SeeOther(w, r, "/settings/users") } func (h settings) bosmang(w http.ResponseWriter, r *http.Request) error { diff --git a/handlers/settings_user.go b/handlers/settings_user.go index 647bfb1a2..77160431a 100644 --- a/handlers/settings_user.go +++ b/handlers/settings_user.go @@ -55,7 +55,7 @@ func (h settings) userPrefSave(w http.ResponseWriter, r *http.Request) error { if oldFewerNums && !args.User.Settings.FewerNumbers && args.User.Settings.FewerNumbersLockUntil.After(ztime.Now()) { zhttp.FlashError(w, "Nice try") - return zhttp.SeeOther(w, "/user/pref") + return SeeOther(w, r, "/user/pref") } if args.FewerNumbersLock != "" { @@ -99,7 +99,7 @@ func (h settings) userPrefSave(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/saved|Saved!")) - return zhttp.SeeOther(w, "/user/pref") + return SeeOther(w, r, "/user/pref") } func (h settings) userDashboardWidget(w http.ResponseWriter, r *http.Request) error { @@ -213,7 +213,7 @@ func (h settings) userDashboardSave(w http.ResponseWriter, r *http.Request) erro return err } zhttp.Flash(w, T(r.Context(), "notify/reset-to-default|Reset to defaults!")) - return zhttp.SeeOther(w, "/user/dashboard") + return SeeOther(w, r, "/user/dashboard") } if len(args.Widgets) == 0 { @@ -257,7 +257,7 @@ func (h settings) userDashboardSave(w http.ResponseWriter, r *http.Request) erro } zhttp.Flash(w, "Saved!") - return zhttp.SeeOther(w, "/user/dashboard") + return SeeOther(w, r, "/user/dashboard") } func (h settings) userAuth(verr *zvalidate.Validator) zhttp.HandlerFunc { diff --git a/handlers/user.go b/handlers/user.go index 5011aabb9..cfe0186ea 100644 --- a/handlers/user.go +++ b/handlers/user.go @@ -54,7 +54,7 @@ func (h user) mount(r chi.Router) { rate.Post("/user/requestlogin", zhttp.Wrap(h.requestLogin)) r.Get("/user/requestlogin", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Redirect, as panic()s and such can end up here. - zhttp.SeeOther(w, "/user/new") + SeeOther(w, r, "/user/new") })) rate.Post("/user/totplogin", zhttp.Wrap(h.totpLogin)) rate.Get("/user/reset/{key}", zhttp.Wrap(h.reset)) @@ -76,7 +76,7 @@ func (h user) mount(r chi.Router) { func (h user) login(w http.ResponseWriter, r *http.Request) error { u := User(r.Context()) if u != nil && u.ID > 0 { - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } return zhttp.Template(w, "user.gohtml", struct { @@ -88,7 +88,7 @@ func (h user) login(w http.ResponseWriter, r *http.Request) error { func (h user) forgot(w http.ResponseWriter, r *http.Request) error { u := User(r.Context()) if u != nil && u.ID > 0 { - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } return zhttp.Template(w, "user_forgot_pw.gohtml", struct { @@ -102,7 +102,7 @@ func (h user) forgot(w http.ResponseWriter, r *http.Request) error { func (h user) requestReset(w http.ResponseWriter, r *http.Request) error { u := User(r.Context()) if u != nil && u.ID > 0 { - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } // Legacy email flow. @@ -118,7 +118,7 @@ func (h user) requestReset(w http.ResponseWriter, r *http.Request) error { if err != nil { if zdb.ErrNoRows(err) { zhttp.FlashError(w, T(r.Context(), "error/reset-user-no-account|Not an account on this site: %(email)", args.Email)) - return zhttp.SeeOther(w, fmt.Sprintf("/user/new?email=%s", url.QueryEscape(args.Email))) + return SeeOther(w, r, fmt.Sprintf("/user/new?email=%s", url.QueryEscape(args.Email))) } return err } @@ -142,13 +142,13 @@ func (h user) requestReset(w http.ResponseWriter, r *http.Request) error { }) zhttp.Flash(w, T(r.Context(), "notify/reset-user-sent|Email sent to %(email)", args.Email)) - return zhttp.SeeOther(w, "/user/forgot") + return SeeOther(w, r, "/user/forgot") } func (h user) requestLogin(w http.ResponseWriter, r *http.Request) error { u := User(r.Context()) if u != nil && u.ID > 0 { // Already logged in. - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } args := struct { @@ -165,14 +165,14 @@ func (h user) requestLogin(w http.ResponseWriter, r *http.Request) error { if err != nil { if zdb.ErrNoRows(err) { zhttp.FlashError(w, T(r.Context(), "error/login-not-found|User %(email) not found", args.Email)) - return zhttp.SeeOther(w, "/user/new") + return SeeOther(w, r, "/user/new") } return err } if user.Password == nil || len(user.Password) == 0 { zhttp.FlashError(w, T(r.Context(), "error/login-no-password|There is no password set for %(email); please reset it", args.Email)) - return zhttp.SeeOther(w, "/user/forgot?email="+url.QueryEscape(args.Email)) + return SeeOther(w, r, "/user/forgot?email="+url.QueryEscape(args.Email)) } err = bcrypt.CompareHashAndPassword(user.Password, []byte(args.Password)) @@ -183,7 +183,7 @@ func (h user) requestLogin(w http.ResponseWriter, r *http.Request) error { zhttp.FlashError(w, "Something went wrong :-( An error has been logged for investigation.") // TODO: should be more generic zlog.FieldsRequest(r).Error(err) } - return zhttp.SeeOther(w, "/user/new?email="+url.QueryEscape(args.Email)) + return SeeOther(w, r, "/user/new?email="+url.QueryEscape(args.Email)) } err = user.Login(r.Context()) @@ -197,7 +197,7 @@ func (h user) requestLogin(w http.ResponseWriter, r *http.Request) error { } auth.SetCookie(w, *user.LoginToken, cookieDomain(Site(r.Context()), r)) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } func (h user) totpLogin(w http.ResponseWriter, r *http.Request) error { @@ -223,7 +223,7 @@ func (h user) totpLogin(w http.ResponseWriter, r *http.Request) error { } if !valid { zhttp.Flash(w, T(r.Context(), "error/login-invalid|Invalid login")) - return zhttp.SeeOther(w, "/user/new") + return SeeOther(w, r, "/user/new") } tokInt, err := strconv.ParseInt(args.Token, 10, 32) @@ -243,7 +243,7 @@ func (h user) totpLogin(w http.ResponseWriter, r *http.Request) error { } auth.SetCookie(w, *u.LoginToken, cookieDomain(Site(r.Context()), r)) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } func (h user) totpForm(w http.ResponseWriter, r *http.Request, loginToken, loginMAC string) error { @@ -297,7 +297,7 @@ func (h user) doReset(w http.ResponseWriter, r *http.Request) error { if args.Password != args.Password2 { zhttp.FlashError(w, T(r.Context(), "error/password-does-not-match|Password confirmation doesn’t match.")) - return zhttp.SeeOther(w, "/user/new") + return SeeOther(w, r, "/user/new") } err = zdb.TX(r.Context(), func(ctx context.Context) error { @@ -317,13 +317,13 @@ func (h user) doReset(w http.ResponseWriter, r *http.Request) error { var vErr *zvalidate.Validator if errors.As(err, &vErr) { zhttp.FlashError(w, fmt.Sprintf("%s", err)) - return zhttp.SeeOther(w, "/user/new") + return SeeOther(w, r, "/user/new") } return err } zhttp.Flash(w, T(r.Context(), "notify/login-after-password-reset|Password reset; use your new password to login.")) - return zhttp.SeeOther(w, "/user/new") + return SeeOther(w, r, "/user/new") } func (h user) logout(w http.ResponseWriter, r *http.Request) error { @@ -337,7 +337,7 @@ func (h user) logout(w http.ResponseWriter, r *http.Request) error { } if isBosmang { auth.ClearCookie(w, Site(r.Context()).Domain(r.Context())) - return zhttp.SeeOther(w, "https://www.goatcounter.com") + return SeeOther(w, r, "https://www.goatcounter.com") } } @@ -348,7 +348,7 @@ func (h user) logout(w http.ResponseWriter, r *http.Request) error { } auth.ClearCookie(w, Site(r.Context()).Domain(r.Context())) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } func (h user) disableTOTP(w http.ResponseWriter, r *http.Request) error { @@ -359,7 +359,7 @@ func (h user) disableTOTP(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/disabled-multi-factor-auth|Multi-factor authentication disabled.")) - return zhttp.SeeOther(w, "/user/auth") + return SeeOther(w, r, "/user/auth") } func (h user) enableTOTP(w http.ResponseWriter, r *http.Request) error { @@ -385,7 +385,7 @@ func (h user) enableTOTP(w http.ResponseWriter, r *http.Request) error { tokGen(-1, nil) != int32(tokInt) && tokGen(1, nil) != int32(tokInt) { zhttp.FlashError(w, mfaError) - return zhttp.SeeOther(w, "/user/auth") + return SeeOther(w, r, "/user/auth") } err = u.EnableTOTP(r.Context()) @@ -394,7 +394,7 @@ func (h user) enableTOTP(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/multi-factor-auth-enabled|Multi-factor authentication enabled.")) - return zhttp.SeeOther(w, "/user/auth") + return SeeOther(w, r, "/user/auth") } func (h user) changePassword(w http.ResponseWriter, r *http.Request) error { @@ -416,13 +416,13 @@ func (h user) changePassword(w http.ResponseWriter, r *http.Request) error { } if !ok { zhttp.FlashError(w, T(r.Context(), "error/incorrect-password|Current password is incorrect.")) - return zhttp.SeeOther(w, "/user/auth") + return SeeOther(w, r, "/user/auth") } } if args.Password != args.Password2 { zhttp.FlashError(w, T(r.Context(), "error/password-does-not-match|Password confirmation doesn’t match.")) - return zhttp.SeeOther(w, "/user/auth") + return SeeOther(w, r, "/user/auth") } err = u.UpdatePassword(r.Context(), args.Password) @@ -430,32 +430,32 @@ func (h user) changePassword(w http.ResponseWriter, r *http.Request) error { var vErr *zvalidate.Validator if errors.As(err, &vErr) { zhttp.FlashError(w, fmt.Sprintf("%s", err)) - return zhttp.SeeOther(w, "/user/auth") + return SeeOther(w, r, "/user/auth") } return err } zhttp.Flash(w, T(r.Context(), "notify/password-changed|Password changed.")) - return zhttp.SeeOther(w, "/user/auth") + return SeeOther(w, r, "/user/auth") } func (h user) resendVerify(w http.ResponseWriter, r *http.Request) error { user := User(r.Context()) if user.EmailVerified { zhttp.Flash(w, T(r.Context(), "notify/email-already-verified|%(email) is already verified.", user.Email)) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } sendEmailVerify(r.Context(), Site(r.Context()), user, goatcounter.Config(r.Context()).EmailFrom) zhttp.Flash(w, T(r.Context(), "notify/sent-to-email|Sent to %(email).", user.Email)) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } func (h user) newAPIToken(w http.ResponseWriter, r *http.Request) error { user := User(r.Context()) if !user.EmailVerified { zhttp.Flash(w, T(r.Context(), "notify/need-email-verification-for-api|You need to verify your email before you can use the API.")) - return zhttp.SeeOther(w, "/user/auth") + return SeeOther(w, r, "/user/auth") } var token goatcounter.APIToken @@ -470,7 +470,7 @@ func (h user) newAPIToken(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/api-token-created|API token created.")) - return zhttp.SeeOther(w, "/user/api") + return SeeOther(w, r, "/user/api") } func (h user) deleteAPIToken(w http.ResponseWriter, r *http.Request) error { @@ -492,7 +492,7 @@ func (h user) deleteAPIToken(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, T(r.Context(), "notify/api-token-removed|API token removed.")) - return zhttp.SeeOther(w, "/user/api") + return SeeOther(w, r, "/user/api") } func sendEmailVerify(ctx context.Context, site *goatcounter.Site, user *goatcounter.User, emailFrom string) { @@ -521,12 +521,12 @@ func (h user) verify(w http.ResponseWriter, r *http.Request) error { if user.EmailVerified { zhttp.Flash(w, T(r.Context(), "notify/email-already-verified|%(email) is already verified.", user.Email)) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } if key != *user.EmailToken { zhttp.FlashError(w, T(r.Context(), "error/wrong-verification-key|Wrong verification key.")) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } err = user.VerifyEmail(r.Context()) @@ -535,10 +535,10 @@ func (h user) verify(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, "%q verified", user.Email) - return zhttp.SeeOther(w, "/") + return SeeOther(w, r, "/") } -// 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()) { diff --git a/handlers/website.go b/handlers/website.go index 9e9e0ce3e..25859203b 100644 --- a/handlers/website.go +++ b/handlers/website.go @@ -92,7 +92,7 @@ func (h website) Mount(r chi.Router, db zdb.DB, dev bool) { } r.Get("/translating", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { - return zhttp.MovedPermanently(w, "/help/translating") + return MovedPermanently(w, r, "/help/translating") })) } @@ -108,22 +108,22 @@ func (h website) MountShared(r chi.Router) { r.Get("/contact", zhttp.Wrap(h.tpl)) r.Get("/terms", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { - return zhttp.MovedPermanently(w, "/help/terms") + return MovedPermanently(w, r, "/help/terms") })) r.Get("/privacy", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { - return zhttp.MovedPermanently(w, "/help/privacy") + return MovedPermanently(w, r, "/help/privacy") })) r.Get("/gdpr", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { - return zhttp.MovedPermanently(w, "/help/gdpr") + return MovedPermanently(w, r, "/help/gdpr") })) r.Get("/api", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { - return zhttp.MovedPermanently(w, "/help/api") + return MovedPermanently(w, r, "/help/api") })) r.Get("/code", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { - return zhttp.MovedPermanently(w, "/help") + return MovedPermanently(w, r, "/help") })) r.Get("/code/*", zhttp.Wrap(func(w http.ResponseWriter, r *http.Request) error { - return zhttp.MovedPermanently(w, "/help/"+chi.URLParam(r, "*")) + return MovedPermanently(w, r, "/help/"+chi.URLParam(r, "*")) })) } @@ -199,7 +199,7 @@ func (h website) contact(w http.ResponseWriter, r *http.Request) error { } zhttp.Flash(w, "Message sent!") - return zhttp.SeeOther(w, args.Return) + return SeeOther(w, r, args.Return) } func (h website) openAPI(w http.ResponseWriter, r *http.Request) error { @@ -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 { @@ -470,7 +470,7 @@ func (h website) doForgot(w http.ResponseWriter, r *http.Request) error { }) zhttp.Flash(w, "List of login URLs mailed to %s", args.Email) - return zhttp.SeeOther(w, "/user/forgot") + return SeeOther(w, r, "/user/forgot") } func (h website) help(w http.ResponseWriter, r *http.Request) error { @@ -481,16 +481,12 @@ 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, "*") if cp == "" { - return zhttp.MovedPermanently(w, "/help/start") + return MovedPermanently(w, r, "/help/start") } { diff --git a/public/backend.js b/public/backend.js index 2bac0798d..ed95875b8 100644 --- a/public/backend.js +++ b/public/backend.js @@ -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 @@ -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) @@ -52,7 +53,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+''}, }) @@ -124,7 +125,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(','). @@ -192,7 +193,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`)) diff --git a/public/dashboard.js b/public/dashboard.js index 37cbdf141..b13e8f6c0 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -26,7 +26,7 @@ return let cid = $('#js-connect-id').text() - window.WEBSOCKET = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + document.location.host + '/loader?id=' + cid) + window.WEBSOCKET = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + document.location.host + BASE_PATH + '/loader?id=' + cid) window.WEBSOCKET.onmessage = function(e) { let msg = JSON.parse(e.data), wid = $(`#dash-widgets div[data-widget=${msg.id}]`) @@ -47,7 +47,7 @@ btn = $(this), pos = btn.offset(), wid = btn.closest('[data-widget]').attr('data-widget'), - url = '/user/dashboard/' + wid, + url = BASE_PATH + '/user/dashboard/' + wid, remove = function() { pop.remove() $(document.body).off('.unpop') @@ -94,7 +94,7 @@ data['total'] = $('.js-total-utc').text() jQuery.ajax({ - url: '/load-widget', + url: BASE_PATH + '/load-widget', type: 'get', data: append_period(data), success: function(data) { @@ -112,7 +112,7 @@ // Reload all widgets on the dashboard. var reload_dashboard = function(done) { jQuery.ajax({ - url: '/', + url: BASE_PATH + '/', data: append_period({ daily: $('#daily').is(':checked'), max: get_original_scale(), @@ -326,7 +326,7 @@ var done = paginate_button($(this), () => { jQuery.ajax({ - url: '/user/view', + url: BASE_PATH + '/user/view', method: 'POST', data: { csrf: CSRF, @@ -387,7 +387,7 @@ $('.list-ref-pages').remove() var done = paginate_button(btn, () => { jQuery.ajax({ - url: '/pages-by-ref', + url: BASE_PATH + '/pages-by-ref', data: append_period({name: btn.text()}), success: function(data) { p.append(data.html) @@ -677,7 +677,7 @@ pages = $(this).closest('.pages-list') let done = paginate_button(btn, () => { jQuery.ajax({ - url: '/load-widget', + url: BASE_PATH + '/load-widget', data: append_period({ widget: pages.attr('data-widget'), daily: $('#daily').is(':checked'), @@ -752,7 +752,7 @@ push_query({showrefs: path}) let done = paginate_button(btn , () => { jQuery.ajax({ - url: '/load-widget', + url: BASE_PATH + '/load-widget', data: append_period({ widget: widget, key: path, @@ -806,7 +806,7 @@ rows.data('pagesize', rows.children().length) let done = paginate_button($(this), () => { jQuery.ajax({ - url: '/load-widget', + url: BASE_PATH + '/load-widget', data: append_period({ widget: chart.attr('data-widget'), total: get_total(), @@ -840,7 +840,7 @@ l.addClass('loading') var done = paginate_button(l, () => { jQuery.ajax({ - url: '/load-widget', + url: BASE_PATH + '/load-widget', data: append_period({ widget: widget, key: key, diff --git a/public/help.js b/public/help.js index a1f8fcf90..7caa9ad7a 100644 --- a/public/help.js +++ b/public/help.js @@ -1,5 +1,7 @@ +let helpIndex = window.location.pathname.lastIndexOf('/help'); +let basePath = window.location.pathname.slice(0, helpIndex === -1 ? 0 : helpIndex) document.querySelector('select').addEventListener('change', function(e) { - window.location = '/code/' + this.value + window.location = basePath + '/help/' + this.value }) document.querySelector('.show-contact').addEventListener('click', function(e) { e.preventDefault() @@ -26,7 +28,7 @@ if (expand) }) }) -if (window.location.pathname === '/help/visitor-counter') { +if (window.location.pathname == basePath + '/help/visitor-counter') { var t = setInterval(function() { if (window.goatcounter && window.goatcounter.visit_count) { clearInterval(t) diff --git a/settings.go b/settings.go index adc9e50e4..8b07d48bf 100644 --- a/settings.go +++ b/settings.go @@ -405,7 +405,7 @@ func (ss SiteSettings) CollectFlags(ctx context.Context) []CollectFlag { return []CollectFlag{ { Label: z18n.T(ctx, "data-collect/label/sessions|Sessions"), - Help: z18n.T(ctx, "data-collect/help/sessions|%[Track unique visitors] for up to 8 hours; if you disable this then someone pressing e.g. F5 to reload the page will just show as 2 pageviews instead of 1.", z18n.Tag("a", `href="/help/sessions"`)), + Help: z18n.T(ctx, "data-collect/help/sessions|%[Track unique visitors] for up to 8 hours; if you disable this then someone pressing e.g. F5 to reload the page will just show as 2 pageviews instead of 1.", z18n.Tag("a", fmt.Sprintf(`href="%s/help/sessions"`, Config(ctx).BasePath))), Flag: CollectSession, }, { diff --git a/site.go b/site.go index 003c7dce6..ed9b7a0f1 100644 --- a/site.go +++ b/site.go @@ -523,17 +523,22 @@ func (s Site) Display(ctx context.Context) string { return fmt.Sprintf("%s.%s", s.Code, znet.RemovePort(Config(ctx).Domain)) } -// URL to this site. -func (s Site) URL(ctx context.Context) string { +// URL to this site, without the scheme. +func (s Site) SchemelessURL(ctx context.Context) string { if s.Cname != nil && s.CnameSetupAt != nil { - return fmt.Sprintf("http%s://%s%s", - map[bool]string{true: "", false: "s"}[Config(ctx).Dev], - *s.Cname, Config(ctx).Port) + return *s.Cname + Config(ctx).Port + Config(ctx).BasePath } - return fmt.Sprintf("http%s://%s.%s%s", - map[bool]string{true: "", false: "s"}[Config(ctx).Dev], - s.Code, Config(ctx).Domain, Config(ctx).Port) + return fmt.Sprintf("%s.%s%s%s", + s.Code, Config(ctx).Domain, Config(ctx).Port, Config(ctx).BasePath) +} + +// URL to this site. +func (s Site) URL(ctx context.Context) string { + if Config(ctx).Dev { + return "http://" + s.SchemelessURL(ctx) + } + return "https://" + s.SchemelessURL(ctx) } // LinkDomainURL creates a valid url to the configured LinkDomain. diff --git a/tpl/_backend_bottom.gohtml b/tpl/_backend_bottom.gohtml index 31da781b0..1f506a364 100644 --- a/tpl/_backend_bottom.gohtml +++ b/tpl/_backend_bottom.gohtml @@ -8,6 +8,7 @@ data-offset="{{.User.Settings.Timezone.Offset}}" data-first-hit-at="{{.Site.FirstHitAt.Unix}}" data-websocket="{{.Websocket}}" + {{if .Base}}data-base-path="{{.Base}}"{{end}} {{if .User.ID}}data-csrf="{{.User.CSRFToken}}"{{end}} > {{- .User.Settings.String | unsafe_js -}} diff --git a/tpl/_backend_signin.gohtml b/tpl/_backend_signin.gohtml index 712e76089..6b212a985 100644 --- a/tpl/_backend_signin.gohtml +++ b/tpl/_backend_signin.gohtml @@ -1,4 +1,4 @@ -
+
@@ -7,4 +7,4 @@
-

{{.T "button/forgot-password|Forgot password?"}}

+

{{.T "button/forgot-password|Forgot password?"}}

diff --git a/tpl/_backend_top.gohtml b/tpl/_backend_top.gohtml index ec4e7db78..6f125c4d6 100644 --- a/tpl/_backend_top.gohtml +++ b/tpl/_backend_top.gohtml @@ -52,22 +52,22 @@ {{- end -}} {{else if has_prefix .Path "/settings/sites/remove/"}} - ←︎ {{.T "top-nav/back|Back"}} + ←︎ {{.T "top-nav/back|Back"}} {{else if has_prefix .Path "/settings/purge/confirm"}} - ←︎ {{.T "top-nav/back|Back"}} + ←︎ {{.T "top-nav/back|Back"}} {{else if has_prefix .Path "/i18n/"}} - ←︎ {{.T "top-nav/back|Back"}} + ←︎ {{.T "top-nav/back|Back"}} {{else if has_prefix .Path "/bosmang/"}} - ←︎ {{.T "top-nav/back|Back"}} + ←︎ {{.T "top-nav/back|Back"}} {{else}} - ←︎ {{.T "top-nav/dashboard|Dashboard"}} + ←︎ {{.T "top-nav/dashboard|Dashboard"}} {{end}}
- {{.T "top-nav/documentation|Help"}} | - {{if .User.AccessSettings}}{{.T "top-nav/settings|Settings"}} |{{end}} - {{.User.EmailShort}} | -
+ {{.T "top-nav/documentation|Help"}} | + {{if .User.AccessSettings}}{{.T "top-nav/settings|Settings"}} |{{end}} + {{.User.EmailShort}} | +
@@ -84,7 +84,7 @@ "timezone-offset" .User.Settings.Timezone.OffsetDisplay )}}
-
{{.T "top-nav/sign-in|Sign in"}}
+
{{.T "top-nav/sign-in|Sign in"}}
{{- end -}} @@ -94,7 +94,7 @@ {{if and .User.ID (before .Site.CreatedAt "2024-04-07")}}
The default theme colours are now set from your system; you can change it back to the - previous by changing it in your user settings + previous by changing it in your user settingsdon’t show again
diff --git a/tpl/_bottom_links.gohtml b/tpl/_bottom_links.gohtml index 9454921fe..748a85f15 100644 --- a/tpl/_bottom_links.gohtml +++ b/tpl/_bottom_links.gohtml @@ -1,9 +1,9 @@