feat: implement localization system (#716)

* lib/localization: implement localization system

Locale files are placed in lib/localization/locales/. If you add a
locale, update manifest.json with available locales.

* Exclude locales from check spelling

* tests(lib/localization): add comprehensive translations test

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(challenge/metarefresh): enable localization

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix: use simple syntax for localization in templ

Also localize CELPHASE into French according to the wishes of the
artist.

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: spelling

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore:(js): fix forbidden patterns

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: add goi18n to tools

Signed-off-by: Xe Iaso <me@xeiaso.net>

* test(lib/localization): dynamically determine the list of supported languages

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
Co-authored-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Laurent Laffont 2025-06-27 19:49:15 +02:00 committed by GitHub
parent c2423d0688
commit ad5430612f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1205 additions and 314 deletions

View file

@ -26,6 +26,7 @@ import (
"github.com/TecharoHQ/anubis/internal/dnsbl"
"github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
@ -87,6 +88,8 @@ func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
}
}
func (s *Server) challengeFor(r *http.Request, difficulty int) string {
var fp [32]byte
if len(s.hs512Secret) == 0 {
@ -126,7 +129,8 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS
cr, rule, err := s.check(r)
if err != nil {
lg.Error("check failed", "err", err)
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy\"")
localizer := localization.GetLocalizer(r)
s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy\"", localizer.T("internal_server_error")))
return
}
@ -210,6 +214,8 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
}
localizer := localization.GetLocalizer(r)
switch cr.Rule {
case config.RuleAllow:
lg.Debug("allowing traffic to origin (explicit)")
@ -220,13 +226,13 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
lg.Info("explicit deny")
if rule == nil {
lg.Error("rule is nil, cannot calculate checksum")
s.respondWithError(w, r, "Internal Server Error: Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.RuleDeny\"")
s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.RuleDeny\"", localizer.T("internal_server_error")))
return true
}
hash := rule.Hash()
lg.Debug("rule hash", "hash", hash)
s.respondWithStatus(w, r, fmt.Sprintf("Access Denied: error code %s", hash), s.policy.StatusCodes.Deny)
s.respondWithStatus(w, r, fmt.Sprintf("%s %s", localizer.T("access_denied"), hash), s.policy.StatusCodes.Deny)
return true
case config.RuleChallenge:
lg.Debug("challenge requested")
@ -237,7 +243,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch
default:
s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"")
s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.Rules\"", localizer.T("internal_server_error")))
return true
}
return false
@ -258,7 +264,12 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
if resp != dnsbl.AllGood {
lg.Info("DNSBL hit", "status", resp.String())
s.respondWithStatus(w, r, fmt.Sprintf("DroneBL reported an entry: %s, see https://dronebl.org/lookup?ip=%s", resp.String(), ip), s.policy.StatusCodes.Deny)
localizer := localization.GetLocalizer(r)
s.respondWithStatus(w, r, fmt.Sprintf("%s: %s, %s https://dronebl.org/lookup?ip=%s",
localizer.T("dronebl_entry"),
resp.String(),
localizer.T("see_dronebl_lookup"),
ip), s.policy.StatusCodes.Deny)
return true
}
}
@ -267,6 +278,7 @@ func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string,
func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
lg := internal.GetRequestLogger(r)
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
if redir == "" {
@ -276,7 +288,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
encoder.Encode(struct {
Error string `json:"error"`
}{
Error: "Invalid invocation of MakeChallenge",
Error: localizer.T("invalid_invocation"),
})
return
}
@ -291,7 +303,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
err := encoder.Encode(struct {
Error string `json:"error"`
}{
Error: "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"makeChallenge\"",
Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")),
})
if err != nil {
lg.Error("failed to encode error response", "err", err)
@ -322,6 +334,7 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
lg := internal.GetRequestLogger(r)
localizer := localization.GetLocalizer(r)
// Adjust cookie path if base prefix is not empty
cookiePath := "/"
@ -333,7 +346,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.ClearCookie(w, anubis.TestCookieName, "/", r.Host)
lg.Warn("user has cookies disabled, this is not an anubis bug")
s.respondWithError(w, r, "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain")
s.respondWithError(w, r, localizer.T("cookies_disabled"))
return
}
@ -343,7 +356,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
redirURL, err := url.ParseRequestURI(redir)
if err != nil {
lg.Error("invalid redirect", "err", err)
s.respondWithError(w, r, "Invalid redirect")
s.respondWithError(w, r, localizer.T("invalid_redirect"))
return
}
// used by the path checker rule
@ -351,18 +364,18 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
urlParsed, err := r.URL.Parse(redir)
if err != nil {
s.respondWithError(w, r, "Redirect URL not parseable")
s.respondWithError(w, r, localizer.T("redirect_not_parseable"))
return
}
if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {
s.respondWithError(w, r, "Redirect domain not allowed")
s.respondWithError(w, r, localizer.T("redirect_domain_not_allowed"))
return
}
cr, rule, err := s.check(r)
if err != nil {
lg.Error("check failed", "err", err)
s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\"")
s.respondWithError(w, r, fmt.Sprintf("%s \"passChallenge\"", localizer.T("internal_server_error")))
return
}
lg = lg.With("check_result", cr)
@ -370,7 +383,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
impl, ok := challenge.Get(rule.Challenge.Algorithm)
if !ok {
lg.Error("check failed", "err", err)
s.respondWithError(w, r, fmt.Sprintf("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to file a bug as Anubis is trying to use challenge method %s but it does not exist in the challenge registry", rule.Challenge.Algorithm))
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return
}
@ -403,7 +416,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
if err != nil {
lg.Error("failed to sign JWT", "err", err)
s.ClearCookie(w, s.cookieName, cookiePath, r.Host)
s.respondWithError(w, r, "failed to sign JWT")
s.respondWithError(w, r, localizer.T("failed_to_sign_jwt"))
return
}

View file

@ -8,6 +8,7 @@ import (
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/web"
"github.com/a-h/templ"
@ -34,7 +35,9 @@ func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput)
q.Set("challenge", in.Challenge)
u.RawQuery = q.Encode()
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", page(in.Challenge, u.String(), in.Rule.Challenge.Difficulty), in.Impressum, in.Challenge, in.Rule.Challenge, in.OGTags)
loc := localization.GetLocalizer(r)
component, err := web.BaseWithChallengeAndOGTags(loc.T("making_sure_not_bot"), page(in.Challenge, u.String(), in.Rule.Challenge.Difficulty, loc), in.Impressum, in.Challenge, in.Rule.Challenge, in.OGTags, loc)
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
}

View file

@ -4,14 +4,15 @@ import (
"fmt"
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/lib/localization"
)
templ page(challenge, redir string, difficulty int) {
templ page(challenge, redir string, difficulty int, loc *localization.SimpleLocalizer) {
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version }/>
<img style="display:none;" style="width:100%;max-width:256px;" src={ anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version }/>
<p id="status">Loading...</p>
<p>Please wait a moment while we ensure the security of your connection.</p>
<p id="status">{ loc.T("loading") }</p>
<p>{ loc.T("connection_security") }</p>
<meta http-equiv="refresh" content={ fmt.Sprintf("%d; url=%s", difficulty, redir) }/>
</div>
}

View file

@ -12,9 +12,10 @@ import (
"fmt"
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/lib/localization"
)
func page(challenge, redir string, difficulty int) templ.Component {
func page(challenge, redir string, difficulty int, loc *localization.SimpleLocalizer) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@ -42,7 +43,7 @@ func page(challenge, redir string, difficulty int) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 11, Col: 165}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 12, Col: 165}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@ -55,26 +56,52 @@ func page(challenge, redir string, difficulty int) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 12, Col: 174}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 13, Col: 174}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><p id=\"status\">Loading...</p><p>Please wait a moment while we ensure the security of your connection.</p><meta http-equiv=\"refresh\" content=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><p id=\"status\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d; url=%s", difficulty, redir))
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T("loading"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 15, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 14, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</p><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(loc.T("connection_security"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 15, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p><meta http-equiv=\"refresh\" content=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d; url=%s", difficulty, redir))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `metarefresh.templ`, Line: 16, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -10,6 +10,7 @@ import (
"github.com/TecharoHQ/anubis/internal"
chall "github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/web"
"github.com/a-h/templ"
@ -29,7 +30,8 @@ func (i *Impl) Setup(mux *http.ServeMux) {
}
func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), in.Impressum, in.Challenge, in.Rule.Challenge, in.OGTags)
loc := localization.GetLocalizer(r)
component, err := web.BaseWithChallengeAndOGTags(loc.T("making_sure_not_bot"), web.Index(loc), in.Impressum, in.Challenge, in.Rule.Challenge, in.OGTags, loc)
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
}

View file

@ -20,6 +20,7 @@ import (
"github.com/TecharoHQ/anubis/internal/dnsbl"
"github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/web"
@ -155,7 +156,7 @@ func New(opts Options) (*Server, error) {
if opts.Policy.Impressum != nil {
registerWithPrefix(anubis.APIPrefix+"imprint", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
templ.Handler(
web.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum),
web.Base(opts.Policy.Impressum.Page.Title, opts.Policy.Impressum.Page, opts.Policy.Impressum, localization.GetLocalizer(r)),
).ServeHTTP(w, r)
}), "GET")
}

View file

@ -12,6 +12,7 @@ import (
"github.com/TecharoHQ/anubis"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/web"
"github.com/a-h/templ"
@ -83,9 +84,11 @@ func randomChance(n int) bool {
}
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) {
localizer := localization.GetLocalizer(r)
if returnHTTPStatusOnly {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Authorization required"))
w.Write([]byte(localizer.T("authorization_required")))
return
}
@ -93,7 +96,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) {
lg.Error("client was given a challenge but does not in fact support gzip compression")
s.respondWithError(w, r, "Client Error: Please ensure your browser is up to date and try again later.")
s.respondWithError(w, r, localizer.T("client_error_browser"))
}
challengesIssued.WithLabelValues("embedded").Add(1)
@ -118,7 +121,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
impl, ok := challenge.Get(rule.Challenge.Algorithm)
if !ok {
lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
s.respondWithError(w, r, fmt.Sprintf("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to file a bug as Anubis is trying to use challenge method %s but it does not exist in the challenge registry", rule.Challenge.Algorithm))
s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
return
}
@ -132,7 +135,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
component, err := impl.Issue(r, lg, in)
if err != nil {
lg.Error("[unexpected] render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this.
s.respondWithError(w, r, "Internal Server Error: please contact the administrator and ask them to look for the logs around \"RenderIndex\"")
s.respondWithError(w, r, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")))
return
}
@ -144,8 +147,10 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
}
func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
localizer := localization.GetLocalizer(r)
templ.Handler(
web.Base("Benchmarking Anubis!", web.Bench(), s.policy.Impressum),
web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer),
).ServeHTTP(w, r)
}
@ -154,7 +159,9 @@ func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, messag
}
func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg string, status int) {
templ.Handler(web.Base("Oh noes!", web.ErrorPage(msg, s.opts.WebmasterEmail), s.policy.Impressum), templ.WithStatus(status)).ServeHTTP(w, r)
localizer := localization.GetLocalizer(r)
templ.Handler(web.Base(localizer.T("oh_noes"), web.ErrorPage(msg, s.opts.WebmasterEmail, localizer), s.policy.Impressum, localizer), templ.WithStatus(status)).ServeHTTP(w, r)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@ -189,15 +196,17 @@ func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request {
func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
if s.next == nil {
localizer := localization.GetLocalizer(r)
redir := r.FormValue("redir")
urlParsed, err := r.URL.Parse(redir)
if err != nil {
s.respondWithStatus(w, r, "Redirect URL not parseable", http.StatusBadRequest)
s.respondWithStatus(w, r, localizer.T("redirect_not_parseable"), http.StatusBadRequest)
return
}
if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {
s.respondWithStatus(w, r, "Redirect domain not allowed", http.StatusBadRequest)
s.respondWithStatus(w, r, localizer.T("redirect_domain_not_allowed"), http.StatusBadRequest)
return
}
@ -207,7 +216,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
}
templ.Handler(
web.Base("You are not a bot!", web.StaticHappy(), s.policy.Impressum),
web.Base(localizer.T("you_are_not_a_bot"), web.StaticHappy(localizer), s.policy.Impressum, localizer),
).ServeHTTP(w, r)
} else {
requestsProxied.WithLabelValues(r.Host).Inc()

View file

@ -0,0 +1,63 @@
{
"loading": "Loading...",
"why_am_i_seeing": "Why am I seeing this?",
"protected_by": "Protected by",
"made_with": "Made with ❤️ in 🇨🇦",
"mascot_design": "Mascot design by",
"ai_companies_explanation": "You are seeing this because the administrator of this website has set up Anubis to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.",
"anubis_compromise": "Anubis is a compromise. Anubis uses a Proof-of-Work scheme in the vein of Hashcash, a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.",
"hack_purpose": "Ultimately, this is a hack whose real purpose is to give a \"good enough\" placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.",
"jshelter_note": "Please note that Anubis requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.",
"version_info": "This website is running Anubis version",
"try_again": "Try again",
"go_home": "Go home",
"contact_webmaster": "or if you believe you should not be blocked, please contact the webmaster at",
"connection_security": "Please wait a moment while we ensure the security of your connection.",
"javascript_required": "Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works. A no-JS solution is a work-in-progress.",
"benchmark_requires_js": "Running the benchmark tool requires JavaScript to be enabled.",
"difficulty": "Difficulty:",
"algorithm": "Algorithm:",
"compare": "Compare:",
"time": "Time",
"iters": "Iters",
"time_a": "Time A",
"iters_a": "Iters A",
"time_b": "Time B",
"iters_b": "Iters B",
"static_check_endpoint": "This is just a check endpoint for your reverse proxy to use.",
"authorization_required": "Authorization required",
"cookies_disabled": "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain",
"access_denied": "Access Denied: error code",
"dronebl_entry": "DroneBL reported an entry",
"see_dronebl_lookup": "see",
"internal_server_error": "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around",
"invalid_redirect": "Invalid redirect",
"redirect_not_parseable": "Redirect URL not parseable",
"redirect_domain_not_allowed": "Redirect domain not allowed",
"failed_to_sign_jwt": "failed to sign JWT",
"invalid_invocation": "Invalid invocation of MakeChallenge",
"client_error_browser": "Client Error: Please ensure your browser is up to date and try again later.",
"oh_noes": "Oh noes!",
"benchmarking_anubis": "Benchmarking Anubis!",
"you_are_not_a_bot": "You are not a bot!",
"making_sure_not_bot": "Making sure you're not a bot!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?",
"js_web_workers_error": "Your browser doesn't support web workers (Anubis uses this to avoid freezing your browser). Do you have a plugin like JShelter installed?",
"js_cookies_error": "Your browser doesn't store cookies. Anubis uses cookies to determine which clients have passed challenges by storing a signed token in a cookie. Please enable storing cookies for this domain. The names of the cookies Anubis stores may vary without notice. Cookie names and values are not part of the public API.",
"js_context_not_secure": "Your context is not secure!",
"js_context_not_secure_msg": "Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Calculating...",
"js_missing_feature": "Missing feature",
"js_challenge_error": "Challenge error!",
"js_challenge_error_msg": "Failed to resolve check algorithm. You may want to reload the page.",
"js_calculating_difficulty": "Calculating...<br/>Difficulty:",
"js_speed": "Speed:",
"js_verification_longer": "Verification is taking longer than expected. Please do not refresh the page.",
"js_success": "Success!",
"js_done_took": "Done! Took",
"js_iterations": "iterations",
"js_finished_reading": "I've finished reading, continue →",
"js_calculation_error": "Calculation error!",
"js_calculation_error_msg": "Failed to calculate challenge:"
}

View file

@ -0,0 +1,63 @@
{
"loading": "Cargando...",
"why_am_i_seeing": "¿Por qué veo esto?",
"protected_by": "Protegido por",
"made_with": "Hecho con ❤️ en 🇨🇦",
"mascot_design": "Diseño de la mascota por",
"ai_companies_explanation": "Ves esto porque el administrador de este sitio web ha configurado Anubis para proteger el servidor contra la plaga de empresas de IA que rastrean agresivamente los sitios web. Esto puede y causa tiempo de inactividad para los sitios web, haciendo que sus recursos sean inaccesibles para todos.",
"anubis_compromise": "Anubis es un compromiso. Anubis utiliza un esquema de Prueba de Trabajo en la línea de Hashcash, un esquema de prueba de trabajo propuesto para reducir el spam por correo electrónico. La idea es que a escala individual, la carga adicional es insignificante, pero a escala de raspadores masivos, se acumula y hace que el raspado sea mucho más costoso.",
"hack_purpose": "En última instancia, esto es un hack cuyo verdadero propósito es dar una solución alternativa \"suficientemente buena\" para que se pueda dedicar más tiempo a la huella digital e identificación de navegadores sin cabeza (por ejemplo: a través de cómo renderizan las fuentes) para que la página de desafío de prueba de trabajo no necesite ser presentada a usuarios que son mucho más propensos a ser legítimos.",
"jshelter_note": "Ten en cuenta que Anubis requiere el uso de características modernas de JavaScript que plugins como JShelter deshabilitarán. Por favor, deshabilita JShelter u otros plugins similares para este dominio.",
"version_info": "Este sitio web utiliza Anubis versión",
"try_again": "Intentar de nuevo",
"go_home": "Inicio",
"contact_webmaster": "o si crees que no deberías estar bloqueado, por favor contacta al webmaster en",
"connection_security": "Espere un momento mientras garantizamos la seguridad de su conexión.",
"javascript_required": "Desafortunadamente, necesitas habilitar JavaScript para pasar este desafío. Esto es requerido porque las empresas de IA han cambiado el contrato social sobre cómo funciona el alojamiento de sitios web. Una solución sin JS está en desarrollo.",
"benchmark_requires_js": "Ejecutar la herramienta de benchmark requiere que JavaScript esté habilitado.",
"difficulty": "Dificultad:",
"algorithm": "Algoritmo:",
"compare": "Comparar:",
"time": "Tiempo",
"iters": "Iteraciones",
"time_a": "Tiempo A",
"iters_a": "Iter. A",
"time_b": "Tiempo B",
"iters_b": "Iter. B",
"static_check_endpoint": "Este es solo un endpoint de verificación para que tu proxy inverso lo use.",
"authorization_required": "Autorización requerida",
"cookies_disabled": "Tu navegador está configurado para deshabilitar las cookies. Anubis requiere cookies para el interés legítimo de asegurar que eres un cliente válido. Por favor habilita las cookies para este dominio",
"access_denied": "Acceso denegado: código de error",
"dronebl_entry": "DroneBL reportó una entrada",
"see_dronebl_lookup": "ver",
"internal_server_error": "Error interno del servidor: el administrador ha configurado mal Anubis. Por favor contacta al administrador y pídele que revise los logs alrededor de",
"invalid_redirect": "Redirección inválida",
"redirect_not_parseable": "URL de redirección no analizable",
"redirect_domain_not_allowed": "Dominio de redirección no permitido",
"failed_to_sign_jwt": "falló al firmar JWT",
"invalid_invocation": "Invocación inválida de MakeChallenge",
"client_error_browser": "Error del cliente: Por favor asegúrate de que tu navegador esté actualizado e inténtalo de nuevo más tarde.",
"oh_noes": "¡Oh no!",
"benchmarking_anubis": "¡Benchmarking de Anubis!",
"you_are_not_a_bot": "¡No eres un robot!",
"making_sure_not_bot": "¡Asegurándonos de que no eres un robot!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Tu navegador no tiene un elemento web.crypto funcional. ¿Estás viendo esta página en un contexto seguro?",
"js_web_workers_error": "Tu navegador no soporta web workers (Anubis los usa para evitar bloquear tu navegador). ¿Tienes un plugin como JShelter instalado?",
"js_cookies_error": "Tu navegador no almacena cookies. Anubis usa cookies para determinar qué clientes han pasado los desafíos almacenando un token firmado en una cookie. Por favor habilita el almacenamiento de cookies para este dominio. Los nombres de las cookies que Anubis almacena pueden variar sin previo aviso. Los nombres y valores de las cookies no son parte de la API pública.",
"js_context_not_secure": "¡Tu contexto no es seguro!",
"js_context_not_secure_msg": "Intenta conectarte a través de HTTPS o informa al administrador para configurar HTTPS. Para más información, consulta <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Calculando...",
"js_missing_feature": "Característica faltante",
"js_challenge_error": "¡Error de desafío!",
"js_challenge_error_msg": "Falló al resolver el algoritmo de verificación. Puedes intentar recargar la página.",
"js_calculating_difficulty": "Calculando...<br/>Dificultad:",
"js_speed": "Velocidad:",
"js_verification_longer": "La verificación está tomando más tiempo del esperado. Por favor no actualices la página.",
"js_success": "¡Éxito!",
"js_done_took": "¡Terminado! Tomó",
"js_iterations": "iteraciones",
"js_finished_reading": "He terminado de leer, continuar →",
"js_calculation_error": "¡Error de cálculo!",
"js_calculation_error_msg": "Falló al calcular el desafío:"
}

View file

@ -0,0 +1,63 @@
{
"loading": "Chargement...",
"why_am_i_seeing": "Pourquoi je vois ceci ?",
"protected_by": "Protégé par",
"made_with": "Fait avec ❤️ au 🇨🇦",
"mascot_design": "Design de la mascotte par",
"ai_companies_explanation": "Vous voyez ceci car l'administrateur de ce site web a configuré Anubis pour protéger le serveur contre le fléau des entreprises d'IA qui scrapent agressivement les sites web. Cela peut et cause des temps d'arrêt pour les sites web, ce qui rend leurs ressources inaccessibles pour tout le monde.",
"anubis_compromise": "Anubis est un compromis. Anubis utilise un schéma de Preuve de Travail dans la veine de Hashcash, un schéma de preuve de travail proposé pour réduire le spam par email. L'idée est qu'à l'échelle individuelle, la charge supplémentaire est négligeable, mais à l'échelle des scrapers de masse, cela s'accumule et rend le scraping beaucoup plus coûteux.",
"hack_purpose": "En fin de compte, c'est un hack dont le véritable objectif est de donner une solution de substitution \"assez bonne\" pour que plus de temps puisse être consacré à l'empreinte digitale et à l'identification des navigateurs sans tête (par exemple : via la façon dont ils font le rendu des polices) afin que la page de défi de preuve de travail n'ait pas besoin d'être présentée aux utilisateurs qui sont beaucoup plus susceptibles d'être légitimes.",
"jshelter_note": "Veuillez noter qu'Anubis nécessite l'utilisation de fonctionnalités JavaScript modernes que des plugins comme JShelter désactiveront. Veuillez désactiver JShelter ou d'autres plugins similaires pour ce domaine.",
"version_info": "Ce site web utilise Anubis version",
"try_again": "Réessayer",
"go_home": "Accueil",
"contact_webmaster": "ou si vous pensez que vous ne devriez pas être bloqué, veuillez contacter le webmaster à",
"connection_security": "Veuillez patienter un instant pendant que nous assurons la sécurité de votre connexion.",
"javascript_required": "Malheureusement, vous devez activer JavaScript pour passer ce défi. Ceci est requis car les entreprises d'IA ont changé le contrat social autour du fonctionnement de l'hébergement de sites web. Une solution sans JS est en cours de développement.",
"benchmark_requires_js": "L'exécution de l'outil de benchmark nécessite l'activation de JavaScript.",
"difficulty": "Difficulté :",
"algorithm": "Algorithme :",
"compare": "Comparer :",
"time": "Temps",
"iters": "Itérations",
"time_a": "Temps A",
"iters_a": "Itér. A",
"time_b": "Temps B",
"iters_b": "Itér. B",
"static_check_endpoint": "Ceci est juste un point de terminaison de vérification pour votre proxy inverse à utiliser.",
"authorization_required": "Autorisation requise",
"cookies_disabled": "Votre navigateur est configuré pour désactiver les cookies. Anubis nécessite des cookies pour l'intérêt légitime de s'assurer que vous êtes un client valide. Veuillez activer les cookies pour ce domaine",
"access_denied": "Accès refusé : code d'erreur",
"dronebl_entry": "DroneBL a signalé une entrée",
"see_dronebl_lookup": "voir",
"internal_server_error": "Erreur interne du serveur : l'administrateur a mal configuré Anubis. Veuillez contacter l'administrateur et lui demander de consulter les logs autour de",
"invalid_redirect": "Redirection invalide",
"redirect_not_parseable": "URL de redirection non analysable",
"redirect_domain_not_allowed": "Domaine de redirection non autorisé",
"failed_to_sign_jwt": "échec de la signature JWT",
"invalid_invocation": "Invocation invalide de MakeChallenge",
"client_error_browser": "Erreur client : Veuillez vous assurer que votre navigateur est à jour et réessayez plus tard.",
"oh_noes": "Oh non !",
"benchmarking_anubis": "Test de performance d'Anubis !",
"you_are_not_a_bot": "Vous n'êtes pas un robot !",
"making_sure_not_bot": "Vérification que vous n'êtes pas un robot !",
"celphase": "PHASE de CEL",
"js_web_crypto_error": "Votre navigateur n'a pas d'élément web.crypto fonctionnel. Consultez-vous cette page dans un contexte sécurisé ?",
"js_web_workers_error": "Votre navigateur ne prend pas en charge les web workers (Anubis les utilise pour éviter de bloquer votre navigateur). Avez-vous un plugin comme JShelter installé ?",
"js_cookies_error": "Votre navigateur ne stocke pas les cookies. Anubis utilise des cookies pour déterminer quels clients ont réussi les défis en stockant un jeton signé dans un cookie. Veuillez activer le stockage des cookies pour ce domaine. Les noms des cookies qu'Anubis stocke peuvent varier sans préavis. Les noms et valeurs des cookies ne font pas partie de l'API publique.",
"js_context_not_secure": "Votre contexte n'est pas sécurisé !",
"js_context_not_secure_msg": "Essayez de vous connecter via HTTPS ou informez l'administrateur de configurer HTTPS. Pour plus d'informations, voir <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Calcul en cours...",
"js_missing_feature": "Fonctionnalité manquante",
"js_challenge_error": "Erreur de défi !",
"js_challenge_error_msg": "Échec de la résolution de l'algorithme de vérification. Vous pouvez essayer de recharger la page.",
"js_calculating_difficulty": "Calcul en cours...<br/>Difficulté :",
"js_speed": "Vitesse :",
"js_verification_longer": "La vérification prend plus de temps que prévu. Veuillez ne pas actualiser la page.",
"js_success": "Succès !",
"js_done_took": "Terminé ! A pris",
"js_iterations": "itérations",
"js_finished_reading": "J'ai fini de lire, continuer →",
"js_calculation_error": "Erreur de calcul !",
"js_calculation_error_msg": "Échec du calcul du défi :"
}

View file

@ -0,0 +1,3 @@
{
"supportedLanguages": ["en", "fr", "es"]
}

View file

@ -0,0 +1,100 @@
package localization
import (
"embed"
"encoding/json"
"net/http"
"strings"
"sync"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
//go:embed locales/*.json
var localeFS embed.FS
type LocalizationService struct {
bundle *i18n.Bundle
}
var (
globalService *LocalizationService
once sync.Once
)
func NewLocalizationService() *LocalizationService {
once.Do(func() {
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// Read all JSON files from the locales directory
entries, err := localeFS.ReadDir("locales")
if err != nil {
// Try fallback - create a minimal service with default messages
globalService = &LocalizationService{bundle: bundle}
return
}
loadedAny := false
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
filePath := "locales/" + entry.Name()
_, err := bundle.LoadMessageFileFS(localeFS, filePath)
if err != nil {
// Log error but continue with other files
continue
}
loadedAny = true
}
}
if !loadedAny {
// If no files were loaded successfully, create minimal service
globalService = &LocalizationService{bundle: bundle}
return
}
globalService = &LocalizationService{bundle: bundle}
})
// Safety check - if globalService is still nil, create a minimal one
if globalService == nil {
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
globalService = &LocalizationService{bundle: bundle}
}
return globalService
}
func (ls *LocalizationService) GetLocalizer(lang string) *i18n.Localizer {
return i18n.NewLocalizer(ls.bundle, lang)
}
func (ls *LocalizationService) GetLocalizerFromRequest(r *http.Request) *i18n.Localizer {
if ls == nil || ls.bundle == nil {
// Fallback to a basic bundle if service is not properly initialized
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
return i18n.NewLocalizer(bundle, "en")
}
acceptLanguage := r.Header.Get("Accept-Language")
return i18n.NewLocalizer(ls.bundle, acceptLanguage, "en")
}
// SimpleLocalizer wraps i18n.Localizer with a more convenient API
type SimpleLocalizer struct {
Localizer *i18n.Localizer
}
// T provides a concise way to localize messages
func (sl *SimpleLocalizer) T(messageID string) string {
return sl.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID})
}
// GetLocalizer creates a localizer based on the request's Accept-Language header
func GetLocalizer(r *http.Request) *SimpleLocalizer {
localizer := NewLocalizationService().GetLocalizerFromRequest(r)
return &SimpleLocalizer{Localizer: localizer}
}

View file

@ -0,0 +1,116 @@
package localization
import (
"encoding/json"
"sort"
"testing"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
func TestLocalizationService(t *testing.T) {
service := NewLocalizationService()
t.Run("English localization", func(t *testing.T) {
localizer := service.GetLocalizer("en")
result := localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "loading"})
if result != "Loading..." {
t.Errorf("Expected 'Loading...', got '%s'", result)
}
})
t.Run("French localization", func(t *testing.T) {
localizer := service.GetLocalizer("fr")
result := localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "loading"})
if result != "Chargement..." {
t.Errorf("Expected 'Chargement...', got '%s'", result)
}
})
t.Run("All required keys exist in English", func(t *testing.T) {
localizer := service.GetLocalizer("en")
requiredKeys := []string{
"loading", "why_am_i_seeing", "protected_by", "made_with",
"mascot_design", "try_again", "go_home", "javascript_required",
}
for _, key := range requiredKeys {
result := localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: key})
if result == "" {
t.Errorf("Key '%s' returned empty string", key)
}
}
})
t.Run("All required keys exist in French", func(t *testing.T) {
localizer := service.GetLocalizer("fr")
requiredKeys := []string{
"loading", "why_am_i_seeing", "protected_by", "made_with",
"mascot_design", "try_again", "go_home", "javascript_required",
}
for _, key := range requiredKeys {
result := localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: key})
if result == "" {
t.Errorf("Key '%s' returned empty string", key)
}
}
})
}
type manifest struct {
SupportedLanguages []string `json:"supported_languages"`
}
func loadManifest(t *testing.T) manifest {
t.Helper()
fin, err := localeFS.Open("locales/manifest.json")
if err != nil {
t.Fatal(err)
}
defer fin.Close()
var result manifest
if err := json.NewDecoder(fin).Decode(&result); err != nil {
t.Fatal(err)
}
return result
}
func TestComprehensiveTranslations(t *testing.T) {
service := NewLocalizationService()
var translations = map[string]any{}
fin, err := localeFS.Open("locales/en.json")
if err != nil {
t.Fatal(err)
}
defer fin.Close()
if err := json.NewDecoder(fin).Decode(&translations); err != nil {
t.Fatal(err)
}
var keys []string
for k := range translations {
keys = append(keys, k)
}
sort.Strings(keys)
for _, lang := range loadManifest(t).SupportedLanguages {
t.Run(lang, func(t *testing.T) {
loc := service.GetLocalizer(lang)
sl := SimpleLocalizer{Localizer: loc}
for _, key := range keys {
t.Run(key, func(t *testing.T) {
if result := sl.T(key); result == "" {
t.Error("key not defined")
}
})
}
})
}
}