package lib

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"slices"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/google/cel-go/common/types"
	"github.com/google/uuid"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"

	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/decaymap"
	"github.com/TecharoHQ/anubis/internal"
	"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"
	"github.com/TecharoHQ/anubis/lib/store"

	// challenge implementations
	_ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh"
	_ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork"
)

var (
	challengesIssued = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "anubis_challenges_issued",
		Help: "The total number of challenges issued",
	}, []string{"method"})

	challengesValidated = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "anubis_challenges_validated",
		Help: "The total number of challenges validated",
	}, []string{"method"})

	droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "anubis_dronebl_hits",
		Help: "The total number of hits from DroneBL",
	}, []string{"status"})

	failedValidations = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "anubis_failed_validations",
		Help: "The total number of failed validations",
	}, []string{"method"})

	requestsProxied = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "anubis_proxied_requests_total",
		Help: "Number of requests proxied through Anubis to upstream targets",
	}, []string{"host"})
)

type Server struct {
	next        http.Handler
	mux         *http.ServeMux
	policy      *policy.ParsedConfig
	OGTags      *ogtags.OGTagCache
	ed25519Priv ed25519.PrivateKey
	hs512Secret []byte
	opts        Options
	store       store.Interface
}

func (s *Server) getTokenKeyfunc() jwt.Keyfunc {
	// return ED25519 key if HS512 is not set
	if len(s.hs512Secret) == 0 {
		return func(token *jwt.Token) (interface{}, error) {
			return s.ed25519Priv.Public().(ed25519.PublicKey), nil
		}
	} else {
		return func(token *jwt.Token) (interface{}, error) {
			return s.hs512Secret, nil
		}
	}
}

func (s *Server) challengeFor(r *http.Request) (*challenge.Challenge, error) {
	ckies := r.CookiesNamed(anubis.TestCookieName)

	if len(ckies) == 0 {
		return s.issueChallenge(r.Context(), r)
	}

	j := store.JSON[challenge.Challenge]{Underlying: s.store}

	ckie := ckies[0]
	chall, err := j.Get(r.Context(), "challenge:"+ckie.Value)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			return s.issueChallenge(r.Context(), r)
		}

		return nil, err
	}

	return &chall, nil
}

func (s *Server) issueChallenge(ctx context.Context, r *http.Request) (*challenge.Challenge, error) {
	id, err := uuid.NewV7()
	if err != nil {
		return nil, err
	}

	var randomData = make([]byte, 256)
	if _, err := rand.Read(randomData); err != nil {
		return nil, err
	}

	chall := challenge.Challenge{
		ID:         id.String(),
		RandomData: fmt.Sprintf("%x", randomData),
		IssuedAt:   time.Now(),
		Metadata: map[string]string{
			"User-Agent": r.Header.Get("User-Agent"),
			"X-Real-Ip":  r.Header.Get("X-Real-Ip"),
		},
	}

	j := store.JSON[challenge.Challenge]{Underlying: s.store}
	if err := j.Set(ctx, "challenge:"+id.String(), chall, 30*time.Minute); err != nil {
		return nil, err
	}

	return &chall, err
}

func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) {
	s.maybeReverseProxy(w, r, true)
}

func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request) {
	s.maybeReverseProxy(w, r, false)
}

func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
	lg := internal.GetRequestLogger(r)

	// Adjust cookie path if base prefix is not empty
	cookiePath := "/"
	if anubis.BasePrefix != "" {
		cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
	}

	cr, rule, err := s.check(r)
	if err != nil {
		lg.Error("check failed", "err", err)
		localizer := localization.GetLocalizer(r)
		s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy\"", localizer.T("internal_server_error")))
		return
	}

	r.Header.Add("X-Anubis-Rule", cr.Name)
	r.Header.Add("X-Anubis-Action", string(cr.Rule))
	lg = lg.With("check_result", cr)
	policy.Applications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)

	ip := r.Header.Get("X-Real-Ip")

	if s.handleDNSBL(w, r, ip, lg) {
		return
	}

	if s.checkRules(w, r, cr, lg, rule) {
		return
	}

	ckie, err := r.Cookie(anubis.CookieName)
	if err != nil {
		lg.Debug("cookie not found", "path", r.URL.Path)
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.RenderIndex(w, r, rule, httpStatusOnly)
		return
	}

	if err := ckie.Valid(); err != nil {
		lg.Debug("cookie is invalid", "err", err)
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.RenderIndex(w, r, rule, httpStatusOnly)
		return
	}

	if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
		lg.Debug("cookie expired", "path", r.URL.Path)
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.RenderIndex(w, r, rule, httpStatusOnly)
		return
	}

	token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, s.getTokenKeyfunc(), jwt.WithExpirationRequired(), jwt.WithStrictDecoding())

	if err != nil || !token.Valid {
		lg.Debug("invalid token", "path", r.URL.Path, "err", err)
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.RenderIndex(w, r, rule, httpStatusOnly)
		return
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		lg.Debug("invalid token claims type", "path", r.URL.Path)
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.RenderIndex(w, r, rule, httpStatusOnly)
		return
	}

	policyRule, ok := claims["policyRule"].(string)
	if !ok {
		lg.Debug("policyRule claim is not a string")
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.RenderIndex(w, r, rule, httpStatusOnly)
		return
	}

	if policyRule != rule.Hash() {
		lg.Debug("user originally passed with a different rule, issuing new challenge", "old", policyRule, "new", rule.Name)
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.RenderIndex(w, r, rule, httpStatusOnly)
		return
	}

	r.Header.Add("X-Anubis-Status", "PASS")
	s.ServeHTTPNext(w, r)
}

func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.CheckResult, lg *slog.Logger, rule *policy.Bot) bool {
	// Adjust cookie path if base prefix is not empty
	cookiePath := "/"
	if anubis.BasePrefix != "" {
		cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
	}

	localizer := localization.GetLocalizer(r)

	switch cr.Rule {
	case config.RuleAllow:
		lg.Debug("allowing traffic to origin (explicit)")
		s.ServeHTTPNext(w, r)
		return true
	case config.RuleDeny:
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		lg.Info("explicit deny")
		if rule == nil {
			lg.Error("rule is nil, cannot calculate checksum")
			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("%s %s", localizer.T("access_denied"), hash), s.policy.StatusCodes.Deny)
		return true
	case config.RuleChallenge:
		lg.Debug("challenge requested")
	case config.RuleBenchmark:
		lg.Debug("serving benchmark page")
		s.RenderBench(w, r)
		return true
	default:
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule)
		s.respondWithError(w, r, fmt.Sprintf("%s \"maybeReverseProxy.Rules\"", localizer.T("internal_server_error")))
		return true
	}
	return false
}

func (s *Server) handleDNSBL(w http.ResponseWriter, r *http.Request, ip string, lg *slog.Logger) bool {
	db := &store.JSON[dnsbl.DroneBLResponse]{Underlying: s.store, Prefix: "dronebl:"}
	if s.policy.DNSBL && ip != "" {
		resp, err := db.Get(r.Context(), ip)
		if err != nil {
			lg.Debug("looking up ip in dnsbl")
			resp, err := dnsbl.Lookup(ip)
			if err != nil {
				lg.Error("can't look up ip in dnsbl", "err", err)
			}
			db.Set(r.Context(), ip, resp, 24*time.Hour)
			droneBLHits.WithLabelValues(resp.String()).Inc()
		}

		if resp != dnsbl.AllGood {
			lg.Info("DNSBL hit", "status", resp.String())
			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
		}
	}
	return false
}

func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
	lg := internal.GetRequestLogger(r)
	localizer := localization.GetLocalizer(r)

	redir := r.FormValue("redir")
	redirURL, err := url.ParseRequestURI(redir)
	if err != nil {
		lg.Error("invalid redirect", "err", err)
		s.respondWithStatus(w, r, localizer.T("invalid_redirect"), http.StatusBadRequest)
		return
	}

	switch redirURL.Scheme {
	case "", "http", "https":
		// allowed
	default:
		lg.Error("XSS attempt blocked, invalid redirect scheme", "scheme", redirURL.Scheme)
		s.respondWithStatus(w, r, localizer.T("invalid_redirect"), http.StatusBadRequest)
		return
	}

	// Adjust cookie path if base prefix is not empty
	cookiePath := "/"
	if anubis.BasePrefix != "" {
		cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
	}

	if _, err := r.Cookie(anubis.TestCookieName); errors.Is(err, http.ErrNoCookie) {
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
		lg.Warn("user has cookies disabled, this is not an anubis bug")
		s.respondWithError(w, r, localizer.T("cookies_disabled"))
		return
	}

	// used by the path checker rule
	r.URL = redirURL

	urlParsed, err := r.URL.Parse(redir)
	if err != nil {
		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, 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, fmt.Sprintf("%s \"passChallenge\"", localizer.T("internal_server_error")))
		return
	}
	lg = lg.With("check_result", cr)

	impl, ok := challenge.Get(rule.Challenge.Algorithm)
	if !ok {
		lg.Error("check failed", "err", err)
		s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
		return
	}

	chall, err := s.challengeFor(r)
	if err != nil {
		lg.Error("check failed", "err", err)
		s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
		return
	}

	in := &challenge.ValidateInput{
		Challenge: chall,
		Rule:      rule,
		Store:     s.store,
	}

	if err := impl.Validate(r, lg, in); err != nil {
		failedValidations.WithLabelValues(rule.Challenge.Algorithm).Inc()
		var cerr *challenge.Error
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		lg.Debug("challenge validate call failed", "err", err)

		switch {
		case errors.As(err, &cerr):
			switch {
			case errors.Is(err, challenge.ErrFailed):
				s.respondWithStatus(w, r, cerr.PublicReason, cerr.StatusCode)
			case errors.Is(err, challenge.ErrInvalidFormat), errors.Is(err, challenge.ErrMissingField):
				s.respondWithError(w, r, cerr.PublicReason)
			}
		}
	}

	// generate JWT cookie
	tokenString, err := s.signJWT(jwt.MapClaims{
		"challenge":  chall.ID,
		"method":     rule.Challenge.Algorithm,
		"policyRule": rule.Hash(),
		"action":     string(cr.Rule),
	})
	if err != nil {
		lg.Error("failed to sign JWT", "err", err)
		s.ClearCookie(w, CookieOpts{Path: cookiePath, Host: r.Host})
		s.respondWithError(w, r, localizer.T("failed_to_sign_jwt"))
		return
	}

	s.SetCookie(w, CookieOpts{Path: cookiePath, Host: r.Host, Value: tokenString})

	challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc()
	lg.Debug("challenge passed, redirecting to app")
	http.Redirect(w, r, redir, http.StatusFound)
}

func cr(name string, rule config.Rule, weight int) policy.CheckResult {
	return policy.CheckResult{
		Name:   name,
		Rule:   rule,
		Weight: weight,
	}
}

// Check evaluates the list of rules, and returns the result
func (s *Server) check(r *http.Request) (policy.CheckResult, *policy.Bot, error) {
	host := r.Header.Get("X-Real-Ip")
	if host == "" {
		return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-Ip header is not set")
	}

	addr := net.ParseIP(host)
	if addr == nil {
		return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host)
	}

	weight := 0

	for _, b := range s.policy.Bots {
		match, err := b.Rules.Check(r)
		if err != nil {
			return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("can't run check %s: %w", b.Name, err)
		}

		if match {
			switch b.Action {
			case config.RuleDeny, config.RuleAllow, config.RuleBenchmark, config.RuleChallenge:
				return cr("bot/"+b.Name, b.Action, weight), &b, nil
			case config.RuleWeigh:
				slog.Debug("adjusting weight", "name", b.Name, "delta", b.Weight.Adjust)
				weight += b.Weight.Adjust
			}
		}
	}

	for _, t := range s.policy.Thresholds {
		result, _, err := t.Program.ContextEval(r.Context(), &policy.ThresholdRequest{Weight: weight})
		if err != nil {
			slog.Error("error when evaluating threshold expression", "expression", t.Expression.String(), "err", err)
			continue
		}

		var matches bool

		if val, ok := result.(types.Bool); ok {
			matches = bool(val)
		}

		if matches {
			return cr("threshold/"+t.Name, t.Action, weight), &policy.Bot{
				Challenge: t.Challenge,
				Rules:     &checker.List{},
			}, nil
		}
	}

	return cr("default/allow", config.RuleAllow, weight), &policy.Bot{
		Challenge: &config.ChallengeRules{
			Difficulty: s.policy.DefaultDifficulty,
			ReportAs:   s.policy.DefaultDifficulty,
			Algorithm:  config.DefaultAlgorithm,
		},
		Rules: &checker.List{},
	}, nil
}

func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
	lg := internal.GetRequestLogger(r)
	localizer := localization.GetLocalizer(r)
	redir := r.FormValue("redir")
	if redir == "" {
		w.WriteHeader(http.StatusBadRequest)
		encoder := json.NewEncoder(w)
		lg.Error("invalid invocation of MakeChallenge", "redir", redir)
		encoder.Encode(struct {
			Error string `json:"error"`
		}{
			Error: localizer.T("invalid_invocation"),
		})
		return
	}
	r.URL.Path = redir
	encoder := json.NewEncoder(w)
	cr, rule, err := s.check(r)
	if err != nil {
		lg.Error("check failed", "err", err)
		w.WriteHeader(http.StatusInternalServerError)
		err := encoder.Encode(struct {
			Error string `json:"error"`
		}{
			Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")),
		})
		if err != nil {
			lg.Error("failed to encode error response", "err", err)
			w.WriteHeader(http.StatusInternalServerError)
		}
		return
	}
	lg = lg.With("check_result", cr)
	chall, err := s.challengeFor(r)
	if err != nil {
		lg.Error("failed to fetch or issue challenge", "err", err)
		w.WriteHeader(http.StatusInternalServerError)
		err := encoder.Encode(struct {
			Error string `json:"error"`
		}{
			Error: fmt.Sprintf("%s \"makeChallenge\"", localizer.T("internal_server_error")),
		})
		if err != nil {
			lg.Error("failed to encode error response", "err", err)
			w.WriteHeader(http.StatusInternalServerError)
		}
		return
	}
	s.SetCookie(w, CookieOpts{Host: r.Host, Name: anubis.TestCookieName, Value: chall.ID})
	err = encoder.Encode(struct {
		Rules     *config.ChallengeRules `json:"rules"`
		Challenge string                 `json:"challenge"`
	}{
		Challenge: chall.RandomData,
		Rules:     rule.Challenge,
	})
	if err != nil {
		lg.Error("failed to encode challenge", "err", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	lg.Debug("made challenge", "challenge", chall, "rules", rule.Challenge, "cr", cr)
	challengesIssued.WithLabelValues("api").Inc()
}

package lib

import (
	"fmt"
	"math/rand"
	"net/http"
	"regexp"
	"slices"
	"strings"
	"time"

	"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"
	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/net/publicsuffix"
)

var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)

type CookieOpts struct {
	Value  string
	Host   string
	Path   string
	Name   string
	Expiry time.Duration
}

func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
	var domain = s.opts.CookieDomain
	var name = anubis.CookieName
	var path = "/"
	if cookieOpts.Name != "" {
		name = cookieOpts.Name
	}
	if cookieOpts.Path != "" {
		path = cookieOpts.Path
	}
	if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {
		if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {
			domain = etld
		}
	}

	if cookieOpts.Expiry == 0 {
		cookieOpts.Expiry = s.opts.CookieExpiration
	}

	http.SetCookie(w, &http.Cookie{
		Name:        name,
		Value:       cookieOpts.Value,
		Expires:     time.Now().Add(cookieOpts.Expiry),
		SameSite:    http.SameSiteNoneMode,
		Domain:      domain,
		Secure:      s.opts.CookieSecure,
		Partitioned: s.opts.CookiePartitioned,
		Path:        path,
	})
}

func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
	var domain = s.opts.CookieDomain
	var name = anubis.CookieName
	var path = "/"
	if cookieOpts.Name != "" {
		name = cookieOpts.Name
	}
	if cookieOpts.Path != "" {
		path = cookieOpts.Path
	}
	if s.opts.CookieDynamicDomain && domainMatchRegexp.MatchString(cookieOpts.Host) {
		if etld, err := publicsuffix.EffectiveTLDPlusOne(cookieOpts.Host); err == nil {
			domain = etld
		}
	}

	http.SetCookie(w, &http.Cookie{
		Name:        name,
		Value:       "",
		MaxAge:      -1,
		Expires:     time.Now().Add(-1 * time.Minute),
		SameSite:    http.SameSiteNoneMode,
		Partitioned: s.opts.CookiePartitioned,
		Domain:      domain,
		Secure:      s.opts.CookieSecure,
		Path:        path,
	})
}

// https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
type UnixRoundTripper struct {
	Transport *http.Transport
}

// set bare minimum stuff
func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	req = req.Clone(req.Context())
	if req.Host == "" {
		req.Host = "localhost"
	}
	req.URL.Host = req.Host // proxy error: no Host in request URL
	req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
	return t.Transport.RoundTrip(req)
}

func randomChance(n int) bool {
	return rand.Intn(n) == 0
}

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(localizer.T("authorization_required")))
		return
	}

	lg := internal.GetRequestLogger(r)

	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, localizer.T("client_error_browser"))
	}

	challengesIssued.WithLabelValues("embedded").Add(1)
	chall, err := s.challengeFor(r)
	if err != nil {
		lg.Error("can't get challenge", "err", "err")
		s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
		s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
		return
	}

	var ogTags map[string]string = nil
	if s.opts.OpenGraph.Enabled {
		var err error
		ogTags, err = s.OGTags.GetOGTags(r.Context(), r.URL, r.Host)
		if err != nil {
			lg.Error("failed to get OG tags", "err", err)
		}
	}

	s.SetCookie(w, CookieOpts{
		Value:  chall.ID,
		Host:   r.Host,
		Path:   "/",
		Name:   anubis.TestCookieName,
		Expiry: 30 * time.Minute,
	})

	impl, ok := challenge.Get(rule.Challenge.Algorithm)
	if !ok {
		lg.Error("check failed", "err", "can't get algorithm", "algorithm", rule.Challenge.Algorithm)
		s.ClearCookie(w, CookieOpts{Name: anubis.TestCookieName, Host: r.Host})
		s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), rule.Challenge.Algorithm))
		return
	}

	in := &challenge.IssueInput{
		Impressum: s.policy.Impressum,
		Rule:      rule,
		Challenge: chall,
		OGTags:    ogTags,
		Store:     s.store,
	}

	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, fmt.Sprintf("%s \"RenderIndex\"", localizer.T("internal_server_error")))
		return
	}

	handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler(
		component,
		templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),
	)))
	handler.ServeHTTP(w, r)
}

func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
	localizer := localization.GetLocalizer(r)

	templ.Handler(
		web.Base(localizer.T("benchmarking_anubis"), web.Bench(localizer), s.policy.Impressum, localizer),
	).ServeHTTP(w, r)
}

func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, message string) {
	s.respondWithStatus(w, r, message, http.StatusInternalServerError)
}

func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg string, status int) {
	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) {
	s.mux.ServeHTTP(w, r)
}

func (s *Server) stripBasePrefixFromRequest(r *http.Request) *http.Request {
	if !s.opts.StripBasePrefix || s.opts.BasePrefix == "" {
		return r
	}

	basePrefix := strings.TrimSuffix(s.opts.BasePrefix, "/")
	path := r.URL.Path

	if !strings.HasPrefix(path, basePrefix) {
		return r
	}

	trimmedPath := strings.TrimPrefix(path, basePrefix)
	if trimmedPath == "" {
		trimmedPath = "/"
	}

	// Clone the request and URL
	reqCopy := r.Clone(r.Context())
	urlCopy := *r.URL
	urlCopy.Path = trimmedPath
	reqCopy.URL = &urlCopy

	return reqCopy
}

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, 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, localizer.T("redirect_domain_not_allowed"), http.StatusBadRequest)
			return
		}

		if redir != "" {
			http.Redirect(w, r, redir, http.StatusFound)
			return
		}

		templ.Handler(
			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()
		r = s.stripBasePrefixFromRequest(r)
		s.next.ServeHTTP(w, r)
	}
}

func (s *Server) signJWT(claims jwt.MapClaims) (string, error) {
	claims["iat"] = time.Now().Unix()
	claims["nbf"] = time.Now().Add(-1 * time.Minute).Unix()
	claims["exp"] = time.Now().Add(s.opts.CookieExpiration).Unix()

	if len(s.hs512Secret) == 0 {
		return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(s.ed25519Priv)
	} else {
		return jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString(s.hs512Secret)
	}
}

package lib

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/data"
	"github.com/TecharoHQ/anubis/internal"
	"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"
	"github.com/TecharoHQ/anubis/xess"
	"github.com/a-h/templ"
)

type Options struct {
	Next                http.Handler
	Policy              *policy.ParsedConfig
	Target              string
	CookieDynamicDomain bool
	CookieDomain        string
	CookieExpiration    time.Duration
	CookiePartitioned   bool
	BasePrefix          string
	WebmasterEmail      string
	RedirectDomains     []string
	ED25519PrivateKey   ed25519.PrivateKey
	HS512Secret         []byte
	StripBasePrefix     bool
	OpenGraph           config.OpenGraph
	ServeRobotsTXT      bool
	CookieSecure        bool
}

func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
	var fin io.ReadCloser
	var err error

	if fname != "" {
		fin, err = os.Open(fname)
		if err != nil {
			return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
		}
	} else {
		fname = "(data)/botPolicies.yaml"
		fin, err = data.BotPolicies.Open("botPolicies.yaml")
		if err != nil {
			return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", fname, err)
		}
	}

	defer func(fin io.ReadCloser) {
		err := fin.Close()
		if err != nil {
			slog.Error("failed to close policy file", "file", fname, "err", err)
		}
	}(fin)

	anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty)
	if err != nil {
		return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
	}
	var validationErrs []error

	for _, b := range anubisPolicy.Bots {
		if _, ok := challenge.Get(b.Challenge.Algorithm); !ok {
			validationErrs = append(validationErrs, fmt.Errorf("%w %s", policy.ErrChallengeRuleHasWrongAlgorithm, b.Challenge.Algorithm))
		}
	}

	if len(validationErrs) != 0 {
		return nil, fmt.Errorf("can't do final validation of Anubis config: %w", errors.Join(validationErrs...))
	}

	return anubisPolicy, err
}

func New(opts Options) (*Server, error) {
	if opts.ED25519PrivateKey == nil && opts.HS512Secret == nil {
		slog.Debug("opts.PrivateKey not set, generating a new one")
		_, priv, err := ed25519.GenerateKey(rand.Reader)
		if err != nil {
			return nil, fmt.Errorf("lib: can't generate private key: %v", err)
		}
		opts.ED25519PrivateKey = priv
	}

	anubis.BasePrefix = opts.BasePrefix

	result := &Server{
		next:        opts.Next,
		ed25519Priv: opts.ED25519PrivateKey,
		hs512Secret: opts.HS512Secret,
		policy:      opts.Policy,
		opts:        opts,
		OGTags:      ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store),
		store:       opts.Policy.Store,
	}

	mux := http.NewServeMux()
	xess.Mount(mux)

	// Helper to add global prefix
	registerWithPrefix := func(pattern string, handler http.Handler, method string) {
		if method != "" {
			method = method + " " // methods must end with a space to register with them
		}

		// Ensure there's no double slash when concatenating BasePrefix and pattern
		basePrefix := strings.TrimSuffix(anubis.BasePrefix, "/")
		prefix := method + basePrefix

		// If pattern doesn't start with a slash, add one
		if !strings.HasPrefix(pattern, "/") {
			pattern = "/" + pattern
		}

		mux.Handle(prefix+pattern, handler)
	}

	// Ensure there's no double slash when concatenating BasePrefix and StaticPath
	stripPrefix := strings.TrimSuffix(anubis.BasePrefix, "/") + anubis.StaticPath
	registerWithPrefix(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), "")

	if opts.ServeRobotsTXT {
		registerWithPrefix("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			http.ServeFileFS(w, r, web.Static, "static/robots.txt")
		}), "GET")
		registerWithPrefix("/.well-known/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			http.ServeFileFS(w, r, web.Static, "static/robots.txt")
		}), "GET")
	}

	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, localization.GetLocalizer(r)),
			).ServeHTTP(w, r)
		}), "GET")
	}

	registerWithPrefix(anubis.APIPrefix+"pass-challenge", http.HandlerFunc(result.PassChallenge), "GET")
	registerWithPrefix(anubis.APIPrefix+"check", http.HandlerFunc(result.maybeReverseProxyHttpStatusOnly), "")
	registerWithPrefix("/", http.HandlerFunc(result.maybeReverseProxyOrPage), "")

	//goland:noinspection GoBoolExpressions
	if anubis.Version == "devel" {
		// make-challenge is only used in tests. Only enable while version is devel
		registerWithPrefix(anubis.APIPrefix+"make-challenge", http.HandlerFunc(result.MakeChallenge), "POST")
	}

	for _, implKind := range challenge.Methods() {
		impl, _ := challenge.Get(implKind)
		impl.Setup(mux)
	}

	result.mux = mux

	return result, nil
}

package main

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/tls"
	"embed"
	"encoding/hex"
	"errors"
	"flag"
	"fmt"
	"io/fs"
	"log"
	"log/slog"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"os/signal"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/data"
	"github.com/TecharoHQ/anubis/internal"
	libanubis "github.com/TecharoHQ/anubis/lib"
	botPolicy "github.com/TecharoHQ/anubis/lib/policy"
	"github.com/TecharoHQ/anubis/lib/policy/config"
	"github.com/TecharoHQ/anubis/lib/thoth"
	"github.com/TecharoHQ/anubis/web"
	"github.com/facebookgo/flagenv"
	_ "github.com/joho/godotenv/autoload"
	"github.com/prometheus/client_golang/prometheus/promhttp"
	healthv1 "google.golang.org/grpc/health/grpc_health_v1"
)

var (
	basePrefix               = flag.String("base-prefix", "", "base prefix (root URL) the application is served under e.g. /myapp")
	bind                     = flag.String("bind", ":8923", "network address to bind HTTP to")
	bindNetwork              = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
	challengeDifficulty      = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge")
	cookieDomain             = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for")
	cookieDynamicDomain      = flag.Bool("cookie-dynamic-domain", false, "if set, automatically set the cookie Domain value based on the request domain")
	cookieExpiration         = flag.Duration("cookie-expiration-time", anubis.CookieDefaultExpirationTime, "The amount of time the authorization cookie is valid for")
	cookiePrefix             = flag.String("cookie-prefix", "techaro.lol-anubis", "prefix for browser cookies created by Anubis")
	cookiePartitioned        = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
	forcedLanguage           = flag.String("forced-language", "", "if set, this language is being used instead of the one from the request's Accept-Language header")
	hs512Secret              = flag.String("hs512-secret", "", "secret used to sign JWTs, uses ed25519 if not set")
	cookieSecure             = flag.Bool("cookie-secure", true, "if true, sets the secure flag on Anubis cookies")
	ed25519PrivateKeyHex     = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
	ed25519PrivateKeyHexFile = flag.String("ed25519-private-key-hex-file", "", "file name containing value for ed25519-private-key-hex")
	metricsBind              = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
	metricsBindNetwork       = flag.String("metrics-bind-network", "tcp", "network family for the metrics server to bind to")
	socketMode               = flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.")
	robotsTxt                = flag.Bool("serve-robots-txt", false, "serve a robots.txt file that disallows all robots")
	policyFname              = flag.String("policy-fname", "", "full path to anubis policy document (defaults to a sensible built-in policy)")
	redirectDomains          = flag.String("redirect-domains", "", "list of domains separated by commas which anubis is allowed to redirect to. Leaving this unset allows any domain.")
	slogLevel                = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
	stripBasePrefix          = flag.Bool("strip-base-prefix", false, "if true, strips the base prefix from requests forwarded to the target server")
	target                   = flag.String("target", "http://localhost:3923", "target to reverse proxy to, set to an empty string to disable proxying when only using auth request")
	targetSNI                = flag.String("target-sni", "", "if set, the value of the TLS handshake hostname when forwarding requests to the target")
	targetHost               = flag.String("target-host", "", "if set, the value of the Host header when forwarding requests to the target")
	targetInsecureSkipVerify = flag.Bool("target-insecure-skip-verify", false, "if true, skips TLS validation for the backend")
	healthcheck              = flag.Bool("healthcheck", false, "run a health check against Anubis")
	useRemoteAddress         = flag.Bool("use-remote-address", false, "read the client's IP address from the network request, useful for debugging and running Anubis on bare metal")
	debugBenchmarkJS         = flag.Bool("debug-benchmark-js", false, "respond to every request with a challenge for benchmarking hashrate")
	ogPassthrough            = flag.Bool("og-passthrough", false, "enable Open Graph tag passthrough")
	ogTimeToLive             = flag.Duration("og-expiry-time", 24*time.Hour, "Open Graph tag cache expiration time")
	ogCacheConsiderHost      = flag.Bool("og-cache-consider-host", false, "enable or disable the use of the host in the Open Graph tag cache")
	extractResources         = flag.String("extract-resources", "", "if set, extract the static resources to the specified folder")
	webmasterEmail           = flag.String("webmaster-email", "", "if set, displays webmaster's email on the reject page for appeals")
	versionFlag              = flag.Bool("version", false, "print Anubis version")
	xffStripPrivate          = flag.Bool("xff-strip-private", true, "if set, strip private addresses from X-Forwarded-For")

	thothInsecure = flag.Bool("thoth-insecure", false, "if set, connect to Thoth over plain HTTP/2, don't enable this unless support told you to")
	thothURL      = flag.String("thoth-url", "", "if set, URL for Thoth, the IP reputation database for Anubis")
	thothToken    = flag.String("thoth-token", "", "if set, API token for Thoth, the IP reputation database for Anubis")
)

func keyFromHex(value string) (ed25519.PrivateKey, error) {
	keyBytes, err := hex.DecodeString(value)
	if err != nil {
		return nil, fmt.Errorf("supplied key is not hex-encoded: %w", err)
	}

	if len(keyBytes) != ed25519.SeedSize {
		return nil, fmt.Errorf("supplied key is not %d bytes long, got %d bytes", ed25519.SeedSize, len(keyBytes))
	}

	return ed25519.NewKeyFromSeed(keyBytes), nil
}

func doHealthCheck() error {
	resp, err := http.Get("http://localhost" + *metricsBind + "/healthz")
	if err != nil {
		return fmt.Errorf("failed to fetch metrics: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	return nil
}

// parseBindNetFromAddr determine bind network and address based on the given network and address.
func parseBindNetFromAddr(address string) (string, string) {
	defaultScheme := "http://"
	if !strings.Contains(address, "://") {
		if strings.HasPrefix(address, ":") {
			address = defaultScheme + "localhost" + address
		} else {
			address = defaultScheme + address
		}
	}

	bindUri, err := url.Parse(address)
	if err != nil {
		log.Fatal(fmt.Errorf("failed to parse bind URL: %w", err))
	}

	switch bindUri.Scheme {
	case "unix":
		return "unix", bindUri.Path
	case "tcp", "http", "https":
		return "tcp", bindUri.Host
	default:
		log.Fatal(fmt.Errorf("unsupported network scheme %s in address %s", bindUri.Scheme, address))
	}
	return "", address
}

func setupListener(network string, address string) (net.Listener, string) {
	formattedAddress := ""

	if network == "" {
		// keep compatibility
		network, address = parseBindNetFromAddr(address)
	}

	switch network {
	case "unix":
		formattedAddress = "unix:" + address
	case "tcp":
		if strings.HasPrefix(address, ":") { // assume it's just a port e.g. :4259
			formattedAddress = "http://localhost" + address
		} else {
			formattedAddress = "http://" + address
		}
	default:
		formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
	}

	listener, err := net.Listen(network, address)
	if err != nil {
		log.Fatal(fmt.Errorf("failed to bind to %s: %w", formattedAddress, err))
	}

	// additional permission handling for unix sockets
	if network == "unix" {
		mode, err := strconv.ParseUint(*socketMode, 8, 0)
		if err != nil {
			listener.Close()
			log.Fatal(fmt.Errorf("could not parse socket mode %s: %w", *socketMode, err))
		}

		err = os.Chmod(address, os.FileMode(mode))
		if err != nil {
			err := listener.Close()
			if err != nil {
				log.Printf("failed to close listener: %v", err)
			}
			log.Fatal(fmt.Errorf("could not change socket mode: %w", err))
		}
	}

	return listener, formattedAddress
}

func makeReverseProxy(target string, targetSNI string, targetHost string, insecureSkipVerify bool) (http.Handler, error) {
	targetUri, err := url.Parse(target)
	if err != nil {
		return nil, fmt.Errorf("failed to parse target URL: %w", err)
	}

	transport := http.DefaultTransport.(*http.Transport).Clone()

	// https://github.com/oauth2-proxy/oauth2-proxy/blob/4e2100a2879ef06aea1411790327019c1a09217c/pkg/upstream/http.go#L124
	if targetUri.Scheme == "unix" {
		// clean path up so we don't use the socket path in proxied requests
		addr := targetUri.Path
		targetUri.Path = ""
		// tell transport how to dial unix sockets
		transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
			dialer := net.Dialer{}
			return dialer.DialContext(ctx, "unix", addr)
		}
		// tell transport how to handle the unix url scheme
		transport.RegisterProtocol("unix", libanubis.UnixRoundTripper{Transport: transport})
	}

	if insecureSkipVerify || targetSNI != "" {
		transport.TLSClientConfig = &tls.Config{}
		if insecureSkipVerify {
			slog.Warn("TARGET_INSECURE_SKIP_VERIFY is set to true, TLS certificate validation will not be performed", "target", target)
			transport.TLSClientConfig.InsecureSkipVerify = true
		}
		if targetSNI != "" {
			transport.TLSClientConfig.ServerName = targetSNI
		}
	}

	rp := httputil.NewSingleHostReverseProxy(targetUri)
	rp.Transport = transport

	if targetHost != "" {
		originalDirector := rp.Director
		rp.Director = func(req *http.Request) {
			originalDirector(req)
			req.Host = targetHost
		}
	}

	return rp, nil
}

func main() {
	flagenv.Parse()
	flag.Parse()

	if *versionFlag {
		fmt.Println("Anubis", anubis.Version)
		return
	}

	internal.InitSlog(*slogLevel)
	internal.SetHealth("anubis", healthv1.HealthCheckResponse_NOT_SERVING)

	if *healthcheck {
		log.Println("running healthcheck")
		if err := doHealthCheck(); err != nil {
			log.Fatal(err)
		}
		return
	}

	if *extractResources != "" {
		if err := extractEmbedFS(data.BotPolicies, ".", *extractResources); err != nil {
			log.Fatal(err)
		}
		if err := extractEmbedFS(web.Static, "static", *extractResources); err != nil {
			log.Fatal(err)
		}
		fmt.Printf("Extracted embedded static files to %s\n", *extractResources)
		return
	}

	// install signal handler
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	wg := new(sync.WaitGroup)

	if *metricsBind != "" {
		wg.Add(1)
		go metricsServer(ctx, wg.Done)
	}

	var rp http.Handler
	// when using anubis via Systemd and environment variables, then it is not possible to set targe to an empty string but only to space
	if strings.TrimSpace(*target) != "" {
		var err error
		rp, err = makeReverseProxy(*target, *targetSNI, *targetHost, *targetInsecureSkipVerify)
		if err != nil {
			log.Fatalf("can't make reverse proxy: %v", err)
		}
	}

	if *cookieDomain != "" && *cookieDynamicDomain {
		log.Fatalf("you can't set COOKIE_DOMAIN and COOKIE_DYNAMIC_DOMAIN at the same time")
	}

	// Thoth configuration
	switch {
	case *thothURL != "" && *thothToken == "":
		slog.Warn("THOTH_URL is set but no THOTH_TOKEN is set")
	case *thothURL == "" && *thothToken != "":
		slog.Warn("THOTH_TOKEN is set but no THOTH_URL is set")
	case *thothURL != "" && *thothToken != "":
		slog.Debug("connecting to Thoth")
		thothClient, err := thoth.New(ctx, *thothURL, *thothToken, *thothInsecure)
		if err != nil {
			log.Fatalf("can't dial thoth at %s: %v", *thothURL, err)
		}

		ctx = thoth.With(ctx, thothClient)
	}

	policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty)
	if err != nil {
		log.Fatalf("can't parse policy file: %v", err)
	}

	ruleErrorIDs := make(map[string]string)
	for _, rule := range policy.Bots {
		if rule.Action != config.RuleDeny {
			continue
		}

		hash := rule.Hash()
		ruleErrorIDs[rule.Name] = hash
	}

	// replace the bot policy rules with a single rule that always benchmarks
	if *debugBenchmarkJS {
		policy.Bots = []botPolicy.Bot{{
			Name:   "",
			Rules:  botPolicy.NewHeaderExistsChecker("User-Agent"),
			Action: config.RuleBenchmark,
		}}
	}
	if *basePrefix != "" && !strings.HasPrefix(*basePrefix, "/") {
		log.Fatalf("[misconfiguration] base-prefix must start with a slash, eg: /%s", *basePrefix)
	} else if strings.HasSuffix(*basePrefix, "/") {
		log.Fatalf("[misconfiguration] base-prefix must not end with a slash")
	}
	if *stripBasePrefix && *basePrefix == "" {
		log.Fatalf("[misconfiguration] strip-base-prefix is set to true, but base-prefix is not set, " +
			"this may result in unexpected behavior")
	}

	var ed25519Priv ed25519.PrivateKey
	if *hs512Secret != "" && (*ed25519PrivateKeyHex != "" || *ed25519PrivateKeyHexFile != "") {
		log.Fatal("do not specify both HS512 and ED25519 secrets")
	} else if *hs512Secret != "" {
		ed25519Priv = ed25519.PrivateKey(*hs512Secret)
	} else if *ed25519PrivateKeyHex != "" && *ed25519PrivateKeyHexFile != "" {
		log.Fatal("do not specify both ED25519_PRIVATE_KEY_HEX and ED25519_PRIVATE_KEY_HEX_FILE")
	} else if *ed25519PrivateKeyHex != "" {
		ed25519Priv, err = keyFromHex(*ed25519PrivateKeyHex)
		if err != nil {
			log.Fatalf("failed to parse and validate ED25519_PRIVATE_KEY_HEX: %v", err)
		}
	} else if *ed25519PrivateKeyHexFile != "" {
		hexFile, err := os.ReadFile(*ed25519PrivateKeyHexFile)
		if err != nil {
			log.Fatalf("failed to read ED25519_PRIVATE_KEY_HEX_FILE %s: %v", *ed25519PrivateKeyHexFile, err)
		}

		ed25519Priv, err = keyFromHex(string(bytes.TrimSpace(hexFile)))
		if err != nil {
			log.Fatalf("failed to parse and validate content of ED25519_PRIVATE_KEY_HEX_FILE: %v", err)
		}
	} else {
		_, ed25519Priv, err = ed25519.GenerateKey(rand.Reader)
		if err != nil {
			log.Fatalf("failed to generate ed25519 key: %v", err)
		}

		slog.Warn("generating random key, Anubis will have strange behavior when multiple instances are behind the same load balancer target, for more information: see https://anubis.techaro.lol/docs/admin/installation#key-generation")
	}

	var redirectDomainsList []string
	if *redirectDomains != "" {
		domains := strings.Split(*redirectDomains, ",")
		for _, domain := range domains {
			_, err = url.Parse(domain)
			if err != nil {
				log.Fatalf("cannot parse redirect-domain %q: %s", domain, err.Error())
			}
			redirectDomainsList = append(redirectDomainsList, strings.TrimSpace(domain))
		}
	} else {
		slog.Warn("REDIRECT_DOMAINS is not set, Anubis will only redirect to the same domain a request is coming from, see https://anubis.techaro.lol/docs/admin/configuration/redirect-domains")
	}

	anubis.CookieName = *cookiePrefix + "-auth"
	anubis.TestCookieName = *cookiePrefix + "-cookie-verification"
	anubis.ForcedLanguage = *forcedLanguage

	// If OpenGraph configuration values are not set in the config file, use the
	// values from flags / envvars.
	if !policy.OpenGraph.Enabled {
		policy.OpenGraph.Enabled = *ogPassthrough
		policy.OpenGraph.ConsiderHost = *ogCacheConsiderHost
		policy.OpenGraph.TimeToLive = *ogTimeToLive
		policy.OpenGraph.Override = map[string]string{}
	}

	s, err := libanubis.New(libanubis.Options{
		BasePrefix:          *basePrefix,
		StripBasePrefix:     *stripBasePrefix,
		Next:                rp,
		Policy:              policy,
		ServeRobotsTXT:      *robotsTxt,
		ED25519PrivateKey:   ed25519Priv,
		HS512Secret:         []byte(*hs512Secret),
		CookieDomain:        *cookieDomain,
		CookieDynamicDomain: *cookieDynamicDomain,
		CookieExpiration:    *cookieExpiration,
		CookiePartitioned:   *cookiePartitioned,
		RedirectDomains:     redirectDomainsList,
		Target:              *target,
		WebmasterEmail:      *webmasterEmail,
		OpenGraph:           policy.OpenGraph,
		CookieSecure:        *cookieSecure,
	})
	if err != nil {
		log.Fatalf("can't construct libanubis.Server: %v", err)
	}

	var h http.Handler
	h = s
	h = internal.RemoteXRealIP(*useRemoteAddress, *bindNetwork, h)
	h = internal.XForwardedForToXRealIP(h)
	h = internal.XForwardedForUpdate(*xffStripPrivate, h)
	h = internal.JA4H(h)

	srv := http.Server{Handler: h, ErrorLog: internal.GetFilteredHTTPLogger()}
	listener, listenerUrl := setupListener(*bindNetwork, *bind)
	slog.Info(
		"listening",
		"url", listenerUrl,
		"difficulty", *challengeDifficulty,
		"serveRobotsTXT", *robotsTxt,
		"target", *target,
		"version", anubis.Version,
		"use-remote-address", *useRemoteAddress,
		"debug-benchmark-js", *debugBenchmarkJS,
		"og-passthrough", *ogPassthrough,
		"og-expiry-time", *ogTimeToLive,
		"base-prefix", *basePrefix,
		"cookie-expiration-time", *cookieExpiration,
		"rule-error-ids", ruleErrorIDs,
	)

	go func() {
		<-ctx.Done()
		c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := srv.Shutdown(c); err != nil {
			log.Printf("cannot shut down: %v", err)
		}
	}()

	internal.SetHealth("anubis", healthv1.HealthCheckResponse_SERVING)

	if err := srv.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
	wg.Wait()
}

func metricsServer(ctx context.Context, done func()) {
	defer done()

	mux := http.NewServeMux()
	mux.Handle("/metrics", promhttp.Handler())
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		st, ok := internal.GetHealth("anubis")
		if !ok {
			slog.Error("health service anubis does not exist, file a bug")
		}

		switch st {
		case healthv1.HealthCheckResponse_NOT_SERVING:
			http.Error(w, "NOT OK", http.StatusInternalServerError)
			return
		case healthv1.HealthCheckResponse_SERVING:
			fmt.Fprintln(w, "OK")
			return
		default:
			http.Error(w, "UNKNOWN", http.StatusFailedDependency)
			return
		}
	})

	srv := http.Server{Handler: mux, ErrorLog: internal.GetFilteredHTTPLogger()}
	listener, metricsUrl := setupListener(*metricsBindNetwork, *metricsBind)
	slog.Debug("listening for metrics", "url", metricsUrl)

	go func() {
		<-ctx.Done()
		c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := srv.Shutdown(c); err != nil {
			log.Printf("cannot shut down: %v", err)
		}
	}()

	if err := srv.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

func extractEmbedFS(fsys embed.FS, root string, destDir string) error {
	return fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		relPath, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}

		destPath := filepath.Join(destDir, root, relPath)

		if d.IsDir() {
			return os.MkdirAll(destPath, 0o700)
		}

		embeddedData, err := fs.ReadFile(fsys, path)
		if err != nil {
			return err
		}

		return os.WriteFile(destPath, embeddedData, 0o644)
	})
}

package proofofwork

import (
	"crypto/subtle"
	"fmt"
	"log/slog"
	"net/http"
	"strconv"
	"strings"

	"github.com/TecharoHQ/anubis/internal"
	chall "github.com/TecharoHQ/anubis/lib/challenge"
	"github.com/TecharoHQ/anubis/lib/localization"
	"github.com/TecharoHQ/anubis/web"
	"github.com/a-h/templ"
)

func init() {
	chall.Register("fast", &Impl{Algorithm: "fast"})
	chall.Register("slow", &Impl{Algorithm: "slow"})
}

type Impl struct {
	Algorithm string
}

func (i *Impl) Setup(mux *http.ServeMux) {
	/* no implementation required */
}

func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
	loc := localization.GetLocalizer(r)
	component, err := web.BaseWithChallengeAndOGTags(loc.T("making_sure_not_bot"), web.Index(loc), in.Impressum, in.Challenge.RandomData, in.Rule.Challenge, in.OGTags, loc)
	if err != nil {
		return nil, fmt.Errorf("can't render page: %w", err)
	}

	return component, nil
}

func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error {
	rule := in.Rule
	challenge := in.Challenge.RandomData

	nonceStr := r.FormValue("nonce")
	if nonceStr == "" {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField))
	}

	nonce, err := strconv.Atoi(nonceStr)
	if err != nil {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err))

	}

	elapsedTimeStr := r.FormValue("elapsedTime")
	if elapsedTimeStr == "" {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w elapsedTime", chall.ErrMissingField))
	}

	elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
	if err != nil {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w: elapsedTime: %w", chall.ErrInvalidFormat, err))
	}

	response := r.FormValue("response")
	if response == "" {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField))
	}

	calcString := fmt.Sprintf("%s%d", challenge, nonce)
	calculated := internal.SHA256sum(calcString)

	if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, calculated, response))
	}

	// compare the leading zeroes
	if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
		return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted %d leading zeros but got %s", chall.ErrFailed, rule.Challenge.Difficulty, response))
	}

	lg.Debug("challenge took", "elapsedTime", elapsedTime)
	chall.TimeTaken.WithLabelValues(i.Algorithm).Observe(elapsedTime)

	return nil
}

package policy

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"sync/atomic"

	"github.com/TecharoHQ/anubis/lib/policy/checker"
	"github.com/TecharoHQ/anubis/lib/policy/config"
	"github.com/TecharoHQ/anubis/lib/store"
	"github.com/TecharoHQ/anubis/lib/thoth"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"

	_ "github.com/TecharoHQ/anubis/lib/store/all"
)

var (
	Applications = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "anubis_policy_results",
		Help: "The results of each policy rule",
	}, []string{"rule", "action"})

	ErrChallengeRuleHasWrongAlgorithm = errors.New("config.Bot.ChallengeRules: algorithm is invalid")
	warnedAboutThresholds             = &atomic.Bool{}
)

type ParsedConfig struct {
	orig *config.Config

	Bots              []Bot
	Thresholds        []*Threshold
	DNSBL             bool
	Impressum         *config.Impressum
	OpenGraph         config.OpenGraph
	DefaultDifficulty int
	StatusCodes       config.StatusCodes
	Store             store.Interface
}

func newParsedConfig(orig *config.Config) *ParsedConfig {
	return &ParsedConfig{
		orig:        orig,
		OpenGraph:   orig.OpenGraph,
		StatusCodes: orig.StatusCodes,
	}
}

func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) {
	c, err := config.Load(fin, fname)
	if err != nil {
		return nil, err
	}

	var validationErrs []error

	tc, hasThothClient := thoth.FromContext(ctx)

	result := newParsedConfig(c)
	result.DefaultDifficulty = defaultDifficulty

	for _, b := range c.Bots {
		if berr := b.Valid(); berr != nil {
			validationErrs = append(validationErrs, berr)
			continue
		}

		parsedBot := Bot{
			Name:   b.Name,
			Action: b.Action,
		}

		cl := checker.List{}

		if len(b.RemoteAddr) > 0 {
			c, err := NewRemoteAddrChecker(b.RemoteAddr)
			if err != nil {
				validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s remote addr set: %w", b.Name, err))
			} else {
				cl = append(cl, c)
			}
		}

		if b.UserAgentRegex != nil {
			c, err := NewUserAgentChecker(*b.UserAgentRegex)
			if err != nil {
				validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s user agent regex: %w", b.Name, err))
			} else {
				cl = append(cl, c)
			}
		}

		if b.PathRegex != nil {
			c, err := NewPathChecker(*b.PathRegex)
			if err != nil {
				validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s path regex: %w", b.Name, err))
			} else {
				cl = append(cl, c)
			}
		}

		if len(b.HeadersRegex) > 0 {
			c, err := NewHeadersChecker(b.HeadersRegex)
			if err != nil {
				validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s headers regex map: %w", b.Name, err))
			} else {
				cl = append(cl, c)
			}
		}

		if b.Expression != nil {
			c, err := NewCELChecker(b.Expression)
			if err != nil {
				validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s expressions: %w", b.Name, err))
			} else {
				cl = append(cl, c)
			}
		}

		if b.ASNs != nil {
			if !hasThothClient {
				slog.Warn("You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "asn", "settings", b.ASNs)
				continue
			}

			cl = append(cl, tc.ASNCheckerFor(b.ASNs.Match))
		}

		if b.GeoIP != nil {
			if !hasThothClient {
				slog.Warn("You have specified a Thoth specific check but you have no Thoth client configured. Please read https://anubis.techaro.lol/docs/admin/thoth for more information", "check", "geoip", "settings", b.GeoIP)
				continue
			}

			cl = append(cl, tc.GeoIPCheckerFor(b.GeoIP.Countries))
		}

		if b.Challenge == nil {
			parsedBot.Challenge = &config.ChallengeRules{
				Difficulty: defaultDifficulty,
				ReportAs:   defaultDifficulty,
				Algorithm:  "fast",
			}
		} else {
			parsedBot.Challenge = b.Challenge
			if parsedBot.Challenge.Algorithm == "" {
				parsedBot.Challenge.Algorithm = config.DefaultAlgorithm
			}
		}

		if b.Weight != nil {
			parsedBot.Weight = b.Weight
		}

		result.Impressum = c.Impressum

		parsedBot.Rules = cl

		result.Bots = append(result.Bots, parsedBot)
	}

	for _, t := range c.Thresholds {
		if t.Name == "legacy-anubis-behaviour" && t.Expression.String() == "true" {
			if !warnedAboutThresholds.Load() {
				slog.Warn("configuration file does not contain thresholds, see docs for details on how to upgrade", "fname", fname, "docs_url", "https://anubis.techaro.lol/docs/admin/configuration/thresholds/")
				warnedAboutThresholds.Store(true)
			}

			t.Challenge.Difficulty = defaultDifficulty
			t.Challenge.ReportAs = defaultDifficulty
		}

		threshold, err := ParsedThresholdFromConfig(t)
		if err != nil {
			validationErrs = append(validationErrs, fmt.Errorf("can't compile threshold config for %s: %w", t.Name, err))
			continue
		}

		result.Thresholds = append(result.Thresholds, threshold)
	}

	stFac, ok := store.Get(c.Store.Backend)
	switch ok {
	case true:
		store, err := stFac.Build(ctx, c.Store.Parameters)
		if err != nil {
			validationErrs = append(validationErrs, err)
		} else {
			result.Store = store
		}
	case false:
		validationErrs = append(validationErrs, config.ErrUnknownStoreBackend)
	}

	if len(validationErrs) > 0 {
		return nil, fmt.Errorf("errors validating policy config JSON %s: %w", fname, errors.Join(validationErrs...))
	}

	result.DNSBL = c.DNSBL

	return result, nil
}

package internal

import (
	"errors"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"net/netip"
	"strings"

	"github.com/TecharoHQ/anubis"
	"github.com/sebest/xff"
)

// TODO: move into config
type XFFComputePreferences struct {
	StripPrivate  bool
	StripLoopback bool
	StripCGNAT    bool
	StripLLU      bool
	Flatten       bool
}

var CGNat = netip.MustParsePrefix("100.64.0.0/10")

// UnchangingCache sets the Cache-Control header to cache a response for 1 year if
// and only if the application is compiled in "release" mode by Docker.
func UnchangingCache(next http.Handler) http.Handler {
	//goland:noinspection GoBoolExpressions
	if anubis.Version == "devel" {
		return next
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cache-Control", "public, max-age=31536000")
		next.ServeHTTP(w, r)
	})
}

// RemoteXRealIP sets the X-Real-Ip header to the request's real IP if
// the setting is enabled by the user.
func RemoteXRealIP(useRemoteAddress bool, bindNetwork string, next http.Handler) http.Handler {
	if !useRemoteAddress {
		slog.Debug("skipping middleware, useRemoteAddress is empty")
		return next
	}

	if bindNetwork == "unix" {
		// For local sockets there is no real remote address but the localhost
		// address should be sensible.
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			r.Header.Set("X-Real-Ip", "127.0.0.1")
			next.ServeHTTP(w, r)
		})
	}

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		host, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			panic(err) // this should never happen
		}
		r.Header.Set("X-Real-Ip", host)
		next.ServeHTTP(w, r)
	})
}

// XForwardedForToXRealIP sets the X-Real-Ip header based on the contents
// of the X-Forwarded-For header.
func XForwardedForToXRealIP(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if xffHeader := r.Header.Get("X-Forwarded-For"); r.Header.Get("X-Real-Ip") == "" && xffHeader != "" {
			ip := xff.Parse(xffHeader)
			slog.Debug("setting x-real-ip", "val", ip)
			r.Header.Set("X-Real-Ip", ip)
		}

		next.ServeHTTP(w, r)
	})
}

// XForwardedForUpdate sets or updates the X-Forwarded-For header, adding
// the known remote address to an existing chain if present
func XForwardedForUpdate(stripPrivate bool, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer next.ServeHTTP(w, r)

		pref := XFFComputePreferences{
			StripPrivate:  stripPrivate,
			StripLoopback: true,
			StripCGNAT:    true,
			Flatten:       true,
			StripLLU:      true,
		}

		remoteAddr := r.RemoteAddr
		origXFFHeader := r.Header.Get("X-Forwarded-For")

		if remoteAddr == "@" {
			// remote is a unix socket
			// do not touch chain
			return
		}

		xffHeaderString, err := computeXFFHeader(remoteAddr, origXFFHeader, pref)
		if err != nil {
			slog.Debug("computing X-Forwarded-For header failed", "err", err)
			return
		}

		if len(xffHeaderString) == 0 {
			r.Header.Del("X-Forwarded-For")
		} else {
			r.Header.Set("X-Forwarded-For", xffHeaderString)
		}
	})
}

var (
	ErrCantSplitHostParse = errors.New("internal: unable to net.SplitHostParse")
	ErrCantParseRemoteIP  = errors.New("internal: unable to parse remote IP")
)

func computeXFFHeader(remoteAddr string, origXFFHeader string, pref XFFComputePreferences) (string, error) {
	remoteIP, _, err := net.SplitHostPort(remoteAddr)
	if err != nil {
		return "", fmt.Errorf("%w: %w", ErrCantSplitHostParse, err)
	}
	parsedRemoteIP, err := netip.ParseAddr(remoteIP)
	if err != nil {
		return "", fmt.Errorf("%w: %w", ErrCantParseRemoteIP, err)
	}

	origForwardedList := make([]string, 0, 4)
	if origXFFHeader != "" {
		origForwardedList = strings.Split(origXFFHeader, ",")
		for i := range origForwardedList {
			origForwardedList[i] = strings.TrimSpace(origForwardedList[i])
		}
	}
	origForwardedList = append(origForwardedList, parsedRemoteIP.String())
	forwardedList := make([]string, 0, len(origForwardedList))
	// this behavior is equivalent to
	// ingress-nginx "compute-full-forwarded-for"
	// https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#compute-full-forwarded-for
	//
	// this would be the correct place to strip and/or flatten this list
	//
	// strip - iterate backwards and eliminate configured trusted IPs
	// flatten - only return the last element to avoid spoofing confusion
	//
	// many applications handle this in different ways, but
	// generally they'd be expected to do these two things on
	// their own end to find the first non-spoofed IP
	for i := len(origForwardedList) - 1; i >= 0; i-- {
		segmentIP, err := netip.ParseAddr(origForwardedList[i])
		if err != nil {
			// can't assess this element, so the remainder of the chain
			// can't be trusted. not a fatal error, since anyone can
			// spoof an XFF header
			slog.Debug("failed to parse XFF segment", "err", err)
			break
		}
		if pref.StripPrivate && segmentIP.IsPrivate() {
			continue
		}
		if pref.StripLoopback && segmentIP.IsLoopback() {
			continue
		}
		if pref.StripLLU && segmentIP.IsLinkLocalUnicast() {
			continue
		}
		if pref.StripCGNAT && CGNat.Contains(segmentIP) {
			continue
		}
		forwardedList = append([]string{segmentIP.String()}, forwardedList...)
	}
	var xffHeaderString string
	if len(forwardedList) == 0 {
		xffHeaderString = ""
		return xffHeaderString, nil
	}
	if pref.Flatten {
		xffHeaderString = forwardedList[len(forwardedList)-1]
	} else {
		xffHeaderString = strings.Join(forwardedList, ",")
	}
	return xffHeaderString, nil
}

// NoStoreCache sets the Cache-Control header to no-store for the response.
func NoStoreCache(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cache-Control", "no-store")
		next.ServeHTTP(w, r)
	})
}

// NoBrowsing prevents directory browsing by returning a 404 for any request that ends with a "/".
func NoBrowsing(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasSuffix(r.URL.Path, "/") {
			http.NotFound(w, r)
			return
		}
		next.ServeHTTP(w, r)
	})
}

package metarefresh

import (
	"crypto/subtle"
	"fmt"
	"log/slog"
	"net/http"

	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/lib/challenge"
	"github.com/TecharoHQ/anubis/lib/localization"
	"github.com/TecharoHQ/anubis/web"
	"github.com/a-h/templ"
)

//go:generate go tool github.com/a-h/templ/cmd/templ generate

func init() {
	challenge.Register("metarefresh", &Impl{})
}

type Impl struct{}

func (i *Impl) Setup(mux *http.ServeMux) {}

func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) {
	u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge")
	if err != nil {
		return nil, fmt.Errorf("can't render page: %w", err)
	}

	q := u.Query()
	q.Set("redir", r.URL.String())
	q.Set("challenge", in.Challenge.RandomData)
	u.RawQuery = q.Encode()

	loc := localization.GetLocalizer(r)
	component, err := web.BaseWithChallengeAndOGTags(loc.T("making_sure_not_bot"), page(u.String(), in.Rule.Challenge.Difficulty, loc), in.Impressum, in.Challenge.RandomData, in.Rule.Challenge, in.OGTags, loc)

	if err != nil {
		return nil, fmt.Errorf("can't render page: %w", err)
	}

	return component, nil
}

func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *challenge.ValidateInput) error {
	gotChallenge := r.FormValue("challenge")

	if subtle.ConstantTimeCompare([]byte(in.Challenge.RandomData), []byte(gotChallenge)) != 1 {
		return challenge.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", challenge.ErrFailed, in.Challenge.RandomData, gotChallenge))
	}

	return nil
}

package config

import (
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net"
	"net/http"
	"os"
	"regexp"
	"strings"
	"time"

	"github.com/TecharoHQ/anubis/data"
	"k8s.io/apimachinery/pkg/util/yaml"
)

var (
	ErrNoBotRulesDefined                 = errors.New("config: must define at least one (1) bot rule")
	ErrBotMustHaveName                   = errors.New("config.Bot: must set name")
	ErrBotMustHaveUserAgentOrPath        = errors.New("config.Bot: must set either user_agent_regex, path_regex, headers_regex, or remote_addresses")
	ErrBotMustHaveUserAgentOrPathNotBoth = errors.New("config.Bot: must set either user_agent_regex, path_regex, and not both")
	ErrUnknownAction                     = errors.New("config.Bot: unknown action")
	ErrInvalidUserAgentRegex             = errors.New("config.Bot: invalid user agent regex")
	ErrInvalidPathRegex                  = errors.New("config.Bot: invalid path regex")
	ErrInvalidHeadersRegex               = errors.New("config.Bot: invalid headers regex")
	ErrInvalidCIDR                       = errors.New("config.Bot: invalid CIDR")
	ErrRegexEndsWithNewline              = errors.New("config.Bot: regular expression ends with newline (try >- instead of > in yaml)")
	ErrInvalidImportStatement            = errors.New("config.ImportStatement: invalid source file")
	ErrCantSetBotAndImportValuesAtOnce   = errors.New("config.BotOrImport: can't set bot rules and import values at the same time")
	ErrMustSetBotOrImportRules           = errors.New("config.BotOrImport: rule definition is invalid, you must set either bot rules or an import statement, not both")
	ErrStatusCodeNotValid                = errors.New("config.StatusCode: status code not valid, must be between 100 and 599")
)

type Rule string

const (
	RuleUnknown   Rule = ""
	RuleAllow     Rule = "ALLOW"
	RuleDeny      Rule = "DENY"
	RuleChallenge Rule = "CHALLENGE"
	RuleWeigh     Rule = "WEIGH"
	RuleBenchmark Rule = "DEBUG_BENCHMARK"
)

func (r Rule) Valid() error {
	switch r {
	case RuleAllow, RuleDeny, RuleChallenge, RuleWeigh, RuleBenchmark:
		return nil
	default:
		return ErrUnknownAction
	}
}

const DefaultAlgorithm = "fast"

type BotConfig struct {
	UserAgentRegex *string           `json:"user_agent_regex,omitempty" yaml:"user_agent_regex,omitempty"`
	PathRegex      *string           `json:"path_regex,omitempty" yaml:"path_regex,omitempty"`
	HeadersRegex   map[string]string `json:"headers_regex,omitempty" yaml:"headers_regex,omitempty"`
	Expression     *ExpressionOrList `json:"expression,omitempty" yaml:"expression,omitempty"`
	Challenge      *ChallengeRules   `json:"challenge,omitempty" yaml:"challenge,omitempty"`
	Weight         *Weight           `json:"weight,omitempty" yaml:"weight,omitempty"`
	Name           string            `json:"name" yaml:"name"`
	Action         Rule              `json:"action" yaml:"action"`
	RemoteAddr     []string          `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`

	// Thoth features
	GeoIP *GeoIP `json:"geoip,omitempty"`
	ASNs  *ASNs  `json:"asns,omitempty"`
}

func (b BotConfig) Zero() bool {
	for _, cond := range []bool{
		b.Name != "",
		b.UserAgentRegex != nil,
		b.PathRegex != nil,
		len(b.HeadersRegex) != 0,
		b.Action != "",
		len(b.RemoteAddr) != 0,
		b.Challenge != nil,
		b.GeoIP != nil,
		b.ASNs != nil,
	} {
		if cond {
			return false
		}
	}

	return true
}

func (b *BotConfig) Valid() error {
	var errs []error

	if b.Name == "" {
		errs = append(errs, ErrBotMustHaveName)
	}

	allFieldsEmpty := b.UserAgentRegex == nil &&
		b.PathRegex == nil &&
		len(b.RemoteAddr) == 0 &&
		len(b.HeadersRegex) == 0 &&
		b.ASNs == nil &&
		b.GeoIP == nil

	if allFieldsEmpty && b.Expression == nil {
		errs = append(errs, ErrBotMustHaveUserAgentOrPath)
	}

	if b.UserAgentRegex != nil && b.PathRegex != nil {
		errs = append(errs, ErrBotMustHaveUserAgentOrPathNotBoth)
	}

	if b.UserAgentRegex != nil {
		if strings.HasSuffix(*b.UserAgentRegex, "\n") {
			errs = append(errs, fmt.Errorf("%w: user agent regex: %q", ErrRegexEndsWithNewline, *b.UserAgentRegex))
		}

		if _, err := regexp.Compile(*b.UserAgentRegex); err != nil {
			errs = append(errs, ErrInvalidUserAgentRegex, err)
		}
	}

	if b.PathRegex != nil {
		if strings.HasSuffix(*b.PathRegex, "\n") {
			errs = append(errs, fmt.Errorf("%w: path regex: %q", ErrRegexEndsWithNewline, *b.PathRegex))
		}

		if _, err := regexp.Compile(*b.PathRegex); err != nil {
			errs = append(errs, ErrInvalidPathRegex, err)
		}
	}

	if len(b.HeadersRegex) > 0 {
		for name, expr := range b.HeadersRegex {
			if name == "" {
				continue
			}

			if strings.HasSuffix(expr, "\n") {
				errs = append(errs, fmt.Errorf("%w: header %s regex: %q", ErrRegexEndsWithNewline, name, expr))
			}

			if _, err := regexp.Compile(expr); err != nil {
				errs = append(errs, ErrInvalidHeadersRegex, err)
			}
		}
	}

	if len(b.RemoteAddr) > 0 {
		for _, cidr := range b.RemoteAddr {
			if _, _, err := net.ParseCIDR(cidr); err != nil {
				errs = append(errs, ErrInvalidCIDR, err)
			}
		}
	}

	if b.Expression != nil {
		if err := b.Expression.Valid(); err != nil {
			errs = append(errs, err)
		}
	}

	switch b.Action {
	case RuleAllow, RuleBenchmark, RuleChallenge, RuleDeny, RuleWeigh:
		// okay
	default:
		errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action))
	}

	if b.Action == RuleChallenge && b.Challenge != nil {
		if err := b.Challenge.Valid(); err != nil {
			errs = append(errs, err)
		}
	}

	if b.Action == RuleWeigh && b.Weight == nil {
		b.Weight = &Weight{Adjust: 5}
	}

	if len(errs) != 0 {
		return fmt.Errorf("config: bot entry for %q is not valid:\n%w", b.Name, errors.Join(errs...))
	}

	return nil
}

type ChallengeRules struct {
	Algorithm  string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"`
	Difficulty int    `json:"difficulty,omitempty" yaml:"difficulty,omitempty"`
	ReportAs   int    `json:"report_as,omitempty" yaml:"report_as,omitempty"`
}

var (
	ErrChallengeDifficultyTooLow  = errors.New("config.ChallengeRules: difficulty is too low (must be >= 1)")
	ErrChallengeDifficultyTooHigh = errors.New("config.ChallengeRules: difficulty is too high (must be <= 64)")
	ErrChallengeMustHaveAlgorithm = errors.New("config.ChallengeRules: must have algorithm name set")
)

func (cr ChallengeRules) Valid() error {
	var errs []error

	if cr.Algorithm == "" {
		errs = append(errs, ErrChallengeMustHaveAlgorithm)
	}

	if cr.Difficulty < 1 {
		errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooLow, cr.Difficulty))
	}

	if cr.Difficulty > 64 {
		errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooHigh, cr.Difficulty))
	}

	if len(errs) != 0 {
		return fmt.Errorf("config: challenge rules entry is not valid:\n%w", errors.Join(errs...))
	}

	return nil
}

type ImportStatement struct {
	Import string `json:"import"`
	Bots   []BotConfig
}

func (is *ImportStatement) open() (fs.File, error) {
	if strings.HasPrefix(is.Import, "(data)/") {
		fname := strings.TrimPrefix(is.Import, "(data)/")
		fin, err := data.BotPolicies.Open(fname)
		return fin, err
	}

	return os.Open(is.Import)
}

func (is *ImportStatement) load() error {
	fin, err := is.open()
	if err != nil {
		return fmt.Errorf("%w: %s: %w", ErrInvalidImportStatement, is.Import, err)
	}
	defer fin.Close()

	var imported []BotOrImport
	var result []BotConfig

	if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&imported); err != nil {
		return fmt.Errorf("can't parse %s: %w", is.Import, err)
	}

	var errs []error

	for _, b := range imported {
		if err := b.Valid(); err != nil {
			errs = append(errs, err)
		}

		if b.ImportStatement != nil {
			result = append(result, b.ImportStatement.Bots...)
		}

		if b.BotConfig != nil {
			result = append(result, *b.BotConfig)
		}
	}

	if len(errs) != 0 {
		return fmt.Errorf("config %s is not valid:\n%w", is.Import, errors.Join(errs...))
	}

	is.Bots = result

	return nil
}

func (is *ImportStatement) Valid() error {
	return is.load()
}

type BotOrImport struct {
	*BotConfig       `json:",inline"`
	*ImportStatement `json:",inline"`
}

func (boi *BotOrImport) Valid() error {
	if boi.BotConfig != nil && boi.ImportStatement != nil {
		return ErrCantSetBotAndImportValuesAtOnce
	}

	if boi.BotConfig != nil {
		return boi.BotConfig.Valid()
	}

	if boi.ImportStatement != nil {
		return boi.ImportStatement.Valid()
	}

	return ErrMustSetBotOrImportRules
}

type StatusCodes struct {
	Challenge int `json:"CHALLENGE"`
	Deny      int `json:"DENY"`
}

func (sc StatusCodes) Valid() error {
	var errs []error

	if sc.Challenge == 0 || (sc.Challenge < 100 && sc.Challenge >= 599) {
		errs = append(errs, fmt.Errorf("%w: challenge is %d", ErrStatusCodeNotValid, sc.Challenge))
	}

	if sc.Deny == 0 || (sc.Deny < 100 && sc.Deny >= 599) {
		errs = append(errs, fmt.Errorf("%w: deny is %d", ErrStatusCodeNotValid, sc.Deny))
	}

	if len(errs) != 0 {
		return fmt.Errorf("status codes not valid:\n%w", errors.Join(errs...))
	}

	return nil
}

type fileConfig struct {
	Bots        []BotOrImport       `json:"bots"`
	DNSBL       bool                `json:"dnsbl"`
	OpenGraph   openGraphFileConfig `json:"openGraph,omitempty"`
	Impressum   *Impressum          `json:"impressum,omitempty"`
	StatusCodes StatusCodes         `json:"status_codes"`
	Store       *Store              `json:"store"`
	Thresholds  []Threshold         `json:"thresholds"`
}

func (c *fileConfig) Valid() error {
	var errs []error

	if len(c.Bots) == 0 {
		errs = append(errs, ErrNoBotRulesDefined)
	}

	for i, b := range c.Bots {
		if err := b.Valid(); err != nil {
			errs = append(errs, fmt.Errorf("bot %d: %w", i, err))
		}
	}

	if c.OpenGraph.Enabled {
		if err := c.OpenGraph.Valid(); err != nil {
			errs = append(errs, err)
		}
	}

	if err := c.StatusCodes.Valid(); err != nil {
		errs = append(errs, err)
	}

	for i, t := range c.Thresholds {
		if err := t.Valid(); err != nil {
			errs = append(errs, fmt.Errorf("threshold %d: %w", i, err))
		}
	}

	if c.Store != nil {
		if err := c.Store.Valid(); err != nil {
			errs = append(errs, err)
		}
	}

	if len(errs) != 0 {
		return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...))
	}

	return nil
}

func Load(fin io.Reader, fname string) (*Config, error) {
	c := &fileConfig{
		StatusCodes: StatusCodes{
			Challenge: http.StatusOK,
			Deny:      http.StatusOK,
		},
		Store: &Store{
			Backend: "memory",
		},
	}

	if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&c); err != nil {
		return nil, fmt.Errorf("can't parse policy config YAML %s: %w", fname, err)
	}

	if err := c.Valid(); err != nil {
		return nil, err
	}

	result := &Config{
		DNSBL: c.DNSBL,
		OpenGraph: OpenGraph{
			Enabled:      c.OpenGraph.Enabled,
			ConsiderHost: c.OpenGraph.ConsiderHost,
			Override:     c.OpenGraph.Override,
		},
		StatusCodes: c.StatusCodes,
		Store:       c.Store,
	}

	if c.OpenGraph.TimeToLive != "" {
		// XXX(Xe): already validated in Valid()
		ogTTL, _ := time.ParseDuration(c.OpenGraph.TimeToLive)
		result.OpenGraph.TimeToLive = ogTTL
	}

	var validationErrs []error

	for _, boi := range c.Bots {
		if boi.ImportStatement != nil {
			if err := boi.load(); err != nil {
				validationErrs = append(validationErrs, err)
				continue
			}

			result.Bots = append(result.Bots, boi.ImportStatement.Bots...)
		}

		if boi.BotConfig != nil {
			if err := boi.BotConfig.Valid(); err != nil {
				validationErrs = append(validationErrs, err)
				continue
			}

			result.Bots = append(result.Bots, *boi.BotConfig)
		}
	}

	if c.Impressum != nil {
		if err := c.Impressum.Valid(); err != nil {
			validationErrs = append(validationErrs, err)
		}

		result.Impressum = c.Impressum
	}

	if len(c.Thresholds) == 0 {
		c.Thresholds = DefaultThresholds
	}

	for _, t := range c.Thresholds {
		if err := t.Valid(); err != nil {
			validationErrs = append(validationErrs, err)
			continue
		}

		result.Thresholds = append(result.Thresholds, t)
	}

	if len(validationErrs) > 0 {
		return nil, fmt.Errorf("errors validating policy config %s: %w", fname, errors.Join(validationErrs...))
	}

	return result, nil
}

type Config struct {
	Bots        []BotConfig
	Thresholds  []Threshold
	DNSBL       bool
	Impressum   *Impressum
	OpenGraph   OpenGraph
	StatusCodes StatusCodes
	Store       *Store
}

func (c Config) Valid() error {
	var errs []error

	if len(c.Bots) == 0 {
		errs = append(errs, ErrNoBotRulesDefined)
	}

	for _, b := range c.Bots {
		if err := b.Valid(); err != nil {
			errs = append(errs, err)
		}
	}

	if len(errs) != 0 {
		return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...))
	}

	return nil
}

package main

import (
	"bufio"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"
	"strings"

	"github.com/TecharoHQ/anubis/lib/policy/config"

	"sigs.k8s.io/yaml"
)

var (
	inputFile     = flag.String("input", "", "path to robots.txt file (use - for stdin)")
	outputFile    = flag.String("output", "", "output file path (use - for stdout, defaults to stdout)")
	outputFormat  = flag.String("format", "yaml", "output format: yaml or json")
	baseAction    = flag.String("action", "CHALLENGE", "default action for disallowed paths: ALLOW, DENY, CHALLENGE, WEIGH")
	crawlDelay    = flag.Int("crawl-delay-weight", 0, "if > 0, add weight adjustment for crawl-delay (difficulty adjustment)")
	policyName    = flag.String("name", "robots-txt-policy", "name for the generated policy")
	userAgentDeny = flag.String("deny-user-agents", "DENY", "action for specifically blocked user agents: DENY, CHALLENGE")
	helpFlag      = flag.Bool("help", false, "show help")
)

type RobotsRule struct {
	UserAgent   string
	Disallows   []string
	Allows      []string
	CrawlDelay  int
	IsBlacklist bool // true if this is a specifically denied user agent
}

type AnubisRule struct {
	Expression *config.ExpressionOrList `yaml:"expression,omitempty" json:"expression,omitempty"`
	Challenge  *config.ChallengeRules   `yaml:"challenge,omitempty" json:"challenge,omitempty"`
	Weight     *config.Weight           `yaml:"weight,omitempty" json:"weight,omitempty"`
	Name       string                   `yaml:"name" json:"name"`
	Action     string                   `yaml:"action" json:"action"`
}

func init() {
	flag.Usage = func() {
		fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
		fmt.Fprintf(os.Stderr, "%s [options] -input <robots.txt>\n\n", os.Args[0])
		flag.PrintDefaults()
		fmt.Fprintln(os.Stderr, "\nExamples:")
		fmt.Fprintln(os.Stderr, "  # Convert local robots.txt file")
		fmt.Fprintln(os.Stderr, "  robots2policy -input robots.txt -output policy.yaml")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "  # Convert from URL")
		fmt.Fprintln(os.Stderr, "  robots2policy -input https://example.com/robots.txt -format json")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "  # Read from stdin, write to stdout")
		fmt.Fprintln(os.Stderr, "  curl https://example.com/robots.txt | robots2policy -input -")
		os.Exit(2)
	}
}

func main() {
	flag.Parse()

	if len(flag.Args()) > 0 || *helpFlag || *inputFile == "" {
		flag.Usage()
	}

	// Read robots.txt
	var input io.Reader
	if *inputFile == "-" {
		input = os.Stdin
	} else if strings.HasPrefix(*inputFile, "http://") || strings.HasPrefix(*inputFile, "https://") {
		resp, err := http.Get(*inputFile)
		if err != nil {
			log.Fatalf("failed to fetch robots.txt from URL: %v", err)
		}
		defer resp.Body.Close()
		input = resp.Body
	} else {
		file, err := os.Open(*inputFile)
		if err != nil {
			log.Fatalf("failed to open input file: %v", err)
		}
		defer file.Close()
		input = file
	}

	// Parse robots.txt
	rules, err := parseRobotsTxt(input)
	if err != nil {
		log.Fatalf("failed to parse robots.txt: %v", err)
	}

	// Convert to Anubis rules
	anubisRules := convertToAnubisRules(rules)

	// Check if any rules were generated
	if len(anubisRules) == 0 {
		log.Fatal("no valid rules generated from robots.txt - file may be empty or contain no disallow directives")
	}

	// Generate output
	var output []byte
	switch strings.ToLower(*outputFormat) {
	case "yaml":
		output, err = yaml.Marshal(anubisRules)
	case "json":
		output, err = json.MarshalIndent(anubisRules, "", "  ")
	default:
		log.Fatalf("unsupported output format: %s (use yaml or json)", *outputFormat)
	}

	if err != nil {
		log.Fatalf("failed to marshal output: %v", err)
	}

	// Write output
	if *outputFile == "" || *outputFile == "-" {
		fmt.Print(string(output))
	} else {
		err = os.WriteFile(*outputFile, output, 0644)
		if err != nil {
			log.Fatalf("failed to write output file: %v", err)
		}
		fmt.Printf("Generated Anubis policy written to %s\n", *outputFile)
	}
}

func parseRobotsTxt(input io.Reader) ([]RobotsRule, error) {
	scanner := bufio.NewScanner(input)
	var rules []RobotsRule
	var currentRule *RobotsRule

	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		// Skip empty lines and comments
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		// Split on first colon
		parts := strings.SplitN(line, ":", 2)
		if len(parts) != 2 {
			continue
		}

		directive := strings.TrimSpace(strings.ToLower(parts[0]))
		value := strings.TrimSpace(parts[1])

		switch directive {
		case "user-agent":
			// Start a new rule section
			if currentRule != nil {
				rules = append(rules, *currentRule)
			}
			currentRule = &RobotsRule{
				UserAgent: value,
				Disallows: make([]string, 0),
				Allows:    make([]string, 0),
			}

		case "disallow":
			if currentRule != nil && value != "" {
				currentRule.Disallows = append(currentRule.Disallows, value)
			}

		case "allow":
			if currentRule != nil && value != "" {
				currentRule.Allows = append(currentRule.Allows, value)
			}

		case "crawl-delay":
			if currentRule != nil {
				if delay, err := parseIntSafe(value); err == nil {
					currentRule.CrawlDelay = delay
				}
			}
		}
	}

	// Don't forget the last rule
	if currentRule != nil {
		rules = append(rules, *currentRule)
	}

	// Mark blacklisted user agents (those with "Disallow: /")
	for i := range rules {
		for _, disallow := range rules[i].Disallows {
			if disallow == "/" {
				rules[i].IsBlacklist = true
				break
			}
		}
	}

	return rules, scanner.Err()
}

func parseIntSafe(s string) (int, error) {
	var result int
	_, err := fmt.Sscanf(s, "%d", &result)
	return result, err
}

func convertToAnubisRules(robotsRules []RobotsRule) []AnubisRule {
	var anubisRules []AnubisRule
	ruleCounter := 0

	for _, robotsRule := range robotsRules {
		userAgent := robotsRule.UserAgent

		// Handle crawl delay as weight adjustment (do this first before any continues)
		if robotsRule.CrawlDelay > 0 && *crawlDelay > 0 {
			ruleCounter++
			rule := AnubisRule{
				Name:   fmt.Sprintf("%s-crawl-delay-%d", *policyName, ruleCounter),
				Action: "WEIGH",
				Weight: &config.Weight{Adjust: *crawlDelay},
			}

			if userAgent == "*" {
				rule.Expression = &config.ExpressionOrList{
					All: []string{"true"}, // Always applies
				}
			} else {
				rule.Expression = &config.ExpressionOrList{
					All: []string{fmt.Sprintf("userAgent.contains(%q)", userAgent)},
				}
			}

			anubisRules = append(anubisRules, rule)
		}

		// Handle blacklisted user agents (complete deny/challenge)
		if robotsRule.IsBlacklist {
			ruleCounter++
			rule := AnubisRule{
				Name:   fmt.Sprintf("%s-blacklist-%d", *policyName, ruleCounter),
				Action: *userAgentDeny,
			}

			if userAgent == "*" {
				// This would block everything - convert to a weight adjustment instead
				rule.Name = fmt.Sprintf("%s-global-restriction-%d", *policyName, ruleCounter)
				rule.Action = "WEIGH"
				rule.Weight = &config.Weight{Adjust: 20} // Increase difficulty significantly
				rule.Expression = &config.ExpressionOrList{
					All: []string{"true"}, // Always applies
				}
			} else {
				rule.Expression = &config.ExpressionOrList{
					All: []string{fmt.Sprintf("userAgent.contains(%q)", userAgent)},
				}
			}
			anubisRules = append(anubisRules, rule)
			continue
		}

		// Handle specific disallow rules
		for _, disallow := range robotsRule.Disallows {
			if disallow == "/" {
				continue // Already handled as blacklist above
			}

			ruleCounter++
			rule := AnubisRule{
				Name:   fmt.Sprintf("%s-disallow-%d", *policyName, ruleCounter),
				Action: *baseAction,
			}

			// Build CEL expression
			var conditions []string

			// Add user agent condition if not wildcard
			if userAgent != "*" {
				conditions = append(conditions, fmt.Sprintf("userAgent.contains(%q)", userAgent))
			}

			// Add path condition
			pathCondition := buildPathCondition(disallow)
			conditions = append(conditions, pathCondition)

			rule.Expression = &config.ExpressionOrList{
				All: conditions,
			}

			anubisRules = append(anubisRules, rule)
		}

	}

	return anubisRules
}

func buildPathCondition(robotsPath string) string {
	// Handle wildcards in robots.txt paths
	if strings.Contains(robotsPath, "*") || strings.Contains(robotsPath, "?") {
		// Convert robots.txt wildcards to regex
		regex := regexp.QuoteMeta(robotsPath)
		regex = strings.ReplaceAll(regex, `\*`, `.*`) // * becomes .*
		regex = strings.ReplaceAll(regex, `\?`, `.`)  // ? becomes .
		regex = "^" + regex
		return fmt.Sprintf("path.matches(%q)", regex)
	}

	// Simple prefix match for most cases
	return fmt.Sprintf("path.startsWith(%q)", robotsPath)
}

package thoth

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"github.com/TecharoHQ/anubis/lib/policy/checker"
	iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
)

func (c *Client) GeoIPCheckerFor(countries []string) checker.Impl {
	countryMap := map[string]struct{}{}
	var sb strings.Builder
	fmt.Fprintln(&sb, "GeoIPChecker")
	for _, cc := range countries {
		countryMap[cc] = struct{}{}
		fmt.Fprintln(&sb, cc)
	}

	return &GeoIPChecker{
		IPToASN:   c.IPToASN,
		Countries: countryMap,
		hash:      sb.String(),
	}
}

type GeoIPChecker struct {
	IPToASN   iptoasnv1.IpToASNServiceClient
	Countries map[string]struct{}
	hash      string
}

func (gipc *GeoIPChecker) Check(r *http.Request) (bool, error) {
	ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
	defer cancel()

	ipInfo, err := gipc.IPToASN.Lookup(ctx, &iptoasnv1.LookupRequest{
		IpAddress: r.Header.Get("X-Real-Ip"),
	})
	if err != nil {
		switch {
		case errors.Is(err, context.DeadlineExceeded):
			slog.Debug("error contacting thoth", "err", err, "actionable", false)
			return false, nil
		default:
			slog.Error("error contacting thoth, please contact support", "err", err, "actionable", true)
			return false, nil
		}
	}

	// If IP is not publicly announced, return false
	if !ipInfo.GetAnnounced() {
		return false, nil
	}

	_, ok := gipc.Countries[strings.ToLower(ipInfo.GetCountryCode())]

	return ok, nil
}

func (gipc *GeoIPChecker) Hash() string {
	return gipc.hash
}

package ogtags

import (
	"context"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/TecharoHQ/anubis/lib/policy/config"
	"github.com/TecharoHQ/anubis/lib/store"
)

const (
	maxContentLength = 8 << 20         // 8 MiB is enough for anyone
	httpTimeout      = 5 * time.Second /*todo: make this configurable?*/

	schemeSeparatorLength = 3 // Length of "://"
	querySeparatorLength  = 1 // Length of "?" for query strings
)

type OGTagCache struct {
	cache     store.JSON[map[string]string]
	targetURL *url.URL
	client    *http.Client

	// Pre-built strings for optimization
	unixPrefix          string // "http://unix"
	approvedTags        []string
	approvedPrefixes    []string
	ogTimeToLive        time.Duration
	ogCacheConsiderHost bool
	ogPassthrough       bool
	ogOverride          map[string]string
}

func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface) *OGTagCache {
	// Predefined approved tags and prefixes
	defaultApprovedTags := []string{"description", "keywords", "author"}
	defaultApprovedPrefixes := []string{"og:", "twitter:", "fediverse:"}

	var parsedTargetURL *url.URL
	var err error

	if target == "" {
		// Default to localhost if target is empty
		parsedTargetURL, _ = url.Parse("http://localhost")
	} else {
		parsedTargetURL, err = url.Parse(target)
		if err != nil {
			slog.Debug("og: failed to parse target URL, treating as non-unix", "target", target, "error", err)
			// If parsing fails, treat it as a non-unix target for backward compatibility or default behavior
			// For now, assume it's not a scheme issue but maybe an invalid char, etc.
			// A simple string target might be intended if it's not a full URL.
			parsedTargetURL = &url.URL{Scheme: "http", Host: target} // Assume http if scheme missing and host-like
			if !strings.Contains(target, "://") && !strings.HasPrefix(target, "unix:") {
				// If it looks like just a host/host:port (and not unix), prepend http:// (todo: is this bad...? Trace path to see if i can yell at user to do it right)
				parsedTargetURL, _ = url.Parse("http://" + target) // fetch cares about scheme but anubis doesn't
			}
		}
	}

	client := &http.Client{
		Timeout: httpTimeout,
	}

	// Configure custom transport for Unix sockets
	if parsedTargetURL.Scheme == "unix" {
		socketPath := parsedTargetURL.Path // For unix scheme, path is the socket path
		client.Transport = &http.Transport{
			DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
				return net.Dial("unix", socketPath)
			},
		}
	}

	return &OGTagCache{
		cache: store.JSON[map[string]string]{
			Underlying: backend,
			Prefix:     "ogtags:",
		},
		targetURL:           parsedTargetURL,
		ogPassthrough:       conf.Enabled,
		ogTimeToLive:        conf.TimeToLive,
		ogCacheConsiderHost: conf.ConsiderHost,
		ogOverride:          conf.Override,
		approvedTags:        defaultApprovedTags,
		approvedPrefixes:    defaultApprovedPrefixes,
		client:              client,
		unixPrefix:          "http://unix",
	}
}

// getTarget constructs the target URL string for fetching OG tags.
// Optimized to minimize allocations by building strings directly.
func (c *OGTagCache) getTarget(u *url.URL) string {
	var escapedPath = u.EscapedPath() // will cause an allocation if path contains special characters
	if c.targetURL.Scheme == "unix" {
		// Build URL string directly without creating intermediate URL object
		var sb strings.Builder
		sb.Grow(len(c.unixPrefix) + len(escapedPath) + len(u.RawQuery) + querySeparatorLength) // Pre-allocate
		sb.WriteString(c.unixPrefix)
		sb.WriteString(escapedPath)
		if u.RawQuery != "" {
			sb.WriteByte('?')
			sb.WriteString(u.RawQuery)
		}
		return sb.String()
	}

	// For regular http/https targets, build URL string directly
	var sb strings.Builder
	// Pre-calculate size: scheme + "://" + host + path + "?" + query
	estimatedSize := len(c.targetURL.Scheme) + schemeSeparatorLength + len(c.targetURL.Host) + len(escapedPath) + len(u.RawQuery) + querySeparatorLength
	sb.Grow(estimatedSize)

	sb.WriteString(c.targetURL.Scheme)
	sb.WriteString("://")
	sb.WriteString(c.targetURL.Host)
	sb.WriteString(escapedPath)
	if u.RawQuery != "" {
		sb.WriteByte('?')
		sb.WriteString(u.RawQuery)
	}

	return sb.String()
}

package thoth

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"github.com/TecharoHQ/anubis/internal"
	"github.com/TecharoHQ/anubis/lib/policy/checker"
	iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
)

func (c *Client) ASNCheckerFor(asns []uint32) checker.Impl {
	asnMap := map[uint32]struct{}{}
	var sb strings.Builder
	fmt.Fprintln(&sb, "ASNChecker")
	for _, asn := range asns {
		asnMap[asn] = struct{}{}
		fmt.Fprintln(&sb, "AS", asn)
	}

	return &ASNChecker{
		iptoasn: c.IPToASN,
		asns:    asnMap,
		hash:    internal.FastHash(sb.String()),
	}
}

type ASNChecker struct {
	iptoasn iptoasnv1.IpToASNServiceClient
	asns    map[uint32]struct{}
	hash    string
}

func (asnc *ASNChecker) Check(r *http.Request) (bool, error) {
	ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
	defer cancel()

	ipInfo, err := asnc.iptoasn.Lookup(ctx, &iptoasnv1.LookupRequest{
		IpAddress: r.Header.Get("X-Real-Ip"),
	})
	if err != nil {
		switch {
		case errors.Is(err, context.DeadlineExceeded):
			slog.Debug("error contacting thoth", "err", err, "actionable", false)
			return false, nil
		default:
			slog.Error("error contacting thoth, please contact support", "err", err, "actionable", true)
			return false, nil
		}
	}

	// If IP is not publicly announced, return false
	if !ipInfo.GetAnnounced() {
		return false, nil
	}

	_, ok := asnc.asns[uint32(ipInfo.GetAsNumber())]

	return ok, nil
}

func (asnc *ASNChecker) Hash() string {
	return asnc.hash
}

package policy

import (
	"errors"
	"fmt"
	"net/http"
	"net/netip"
	"regexp"
	"strings"

	"github.com/TecharoHQ/anubis/internal"
	"github.com/TecharoHQ/anubis/lib/policy/checker"
	"github.com/gaissmai/bart"
)

var (
	ErrMisconfiguration = errors.New("[unexpected] policy: administrator misconfiguration")
)

type RemoteAddrChecker struct {
	prefixTable *bart.Lite
	hash        string
}

func NewRemoteAddrChecker(cidrs []string) (checker.Impl, error) {
	table := new(bart.Lite)

	for _, cidr := range cidrs {
		prefix, err := netip.ParsePrefix(cidr)
		if err != nil {
			return nil, fmt.Errorf("%w: range %s not parsing: %w", ErrMisconfiguration, cidr, err)
		}

		table.Insert(prefix)
	}

	return &RemoteAddrChecker{
		prefixTable: table,
		hash:        internal.FastHash(strings.Join(cidrs, ",")),
	}, nil
}

func (rac *RemoteAddrChecker) Check(r *http.Request) (bool, error) {
	host := r.Header.Get("X-Real-Ip")
	if host == "" {
		return false, fmt.Errorf("%w: header X-Real-Ip is not set", ErrMisconfiguration)
	}

	addr, err := netip.ParseAddr(host)
	if err != nil {
		return false, fmt.Errorf("%w: %s is not an IP address: %w", ErrMisconfiguration, host, err)
	}

	return rac.prefixTable.Contains(addr), nil
}

func (rac *RemoteAddrChecker) Hash() string {
	return rac.hash
}

type HeaderMatchesChecker struct {
	header string
	regexp *regexp.Regexp
	hash   string
}

func NewUserAgentChecker(rexStr string) (checker.Impl, error) {
	return NewHeaderMatchesChecker("User-Agent", rexStr)
}

func NewHeaderMatchesChecker(header, rexStr string) (checker.Impl, error) {
	rex, err := regexp.Compile(strings.TrimSpace(rexStr))
	if err != nil {
		return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
	}
	return &HeaderMatchesChecker{strings.TrimSpace(header), rex, internal.FastHash(header + ": " + rexStr)}, nil
}

func (hmc *HeaderMatchesChecker) Check(r *http.Request) (bool, error) {
	if hmc.regexp.MatchString(r.Header.Get(hmc.header)) {
		return true, nil
	}

	return false, nil
}

func (hmc *HeaderMatchesChecker) Hash() string {
	return hmc.hash
}

type PathChecker struct {
	regexp *regexp.Regexp
	hash   string
}

func NewPathChecker(rexStr string) (checker.Impl, error) {
	rex, err := regexp.Compile(strings.TrimSpace(rexStr))
	if err != nil {
		return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
	}
	return &PathChecker{rex, internal.FastHash(rexStr)}, nil
}

func (pc *PathChecker) Check(r *http.Request) (bool, error) {
	if pc.regexp.MatchString(r.URL.Path) {
		return true, nil
	}

	return false, nil
}

func (pc *PathChecker) Hash() string {
	return pc.hash
}

func NewHeaderExistsChecker(key string) checker.Impl {
	return headerExistsChecker{strings.TrimSpace(key)}
}

type headerExistsChecker struct {
	header string
}

func (hec headerExistsChecker) Check(r *http.Request) (bool, error) {
	if r.Header.Get(hec.header) != "" {
		return true, nil
	}

	return false, nil
}

func (hec headerExistsChecker) Hash() string {
	return internal.FastHash(hec.header)
}

func NewHeadersChecker(headermap map[string]string) (checker.Impl, error) {
	var result checker.List
	var errs []error

	for key, rexStr := range headermap {
		if rexStr == ".*" {
			result = append(result, headerExistsChecker{strings.TrimSpace(key)})
			continue
		}

		rex, err := regexp.Compile(strings.TrimSpace(rexStr))
		if err != nil {
			errs = append(errs, fmt.Errorf("while compiling header %s regex %s: %w", key, rexStr, err))
			continue
		}

		result = append(result, &HeaderMatchesChecker{key, rex, internal.FastHash(key + ": " + rexStr)})
	}

	if len(errs) != 0 {
		return nil, errors.Join(errs...)
	}

	return result, nil
}

package ogtags

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"mime"
	"net"
	"net/http"

	"golang.org/x/net/html"
)

var (
	ErrOgHandled = errors.New("og: handled error") // used to indicate that the error was handled and should not be logged
	emptyMap     = map[string]string{}             // used to indicate an empty result in the cache. Can't use nil as it would be a cache miss.
)

// fetchHTMLDocumentWithCache fetches the HTML document from the given URL string,
// preserving the original host header.
func (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr string, originalHost string, cacheKey string) (*html.Node, error) {
	req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to create http request: %w", err)
	}

	// Set the Host header to the original host
	if originalHost != "" {
		req.Host = originalHost
	}

	// Add proxy headers
	req.Header.Set("X-Forwarded-Proto", "https")
	req.Header.Set("User-Agent", "Anubis-OGTag-Fetcher/1.0") // For tracking purposes

	// Send the request
	resp, err := c.client.Do(req)
	if err != nil {
		var netErr net.Error
		if errors.As(err, &netErr) && netErr.Timeout() {
			slog.Debug("og: request timed out", "url", urlStr)
			c.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive/2) // Cache empty result for half the TTL to not spam the server
		}
		return nil, fmt.Errorf("http get failed: %w", err)
	}

	// Ensure the response body is closed
	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
			slog.Debug("og: error closing response body", "url", urlStr, "error", err)
		}
	}(resp.Body)

	if resp.StatusCode != http.StatusOK {
		slog.Debug("og: received non-OK status code", "url", urlStr, "status", resp.StatusCode)
		c.cache.Set(ctx, cacheKey, emptyMap, c.ogTimeToLive) // Cache empty result for non-successful status codes
		return nil, fmt.Errorf("%w: page not found", ErrOgHandled)
	}

	// Check content type
	ct := resp.Header.Get("Content-Type")
	if ct == "" {
		return nil, fmt.Errorf("missing Content-Type header")
	} else {
		mediaType, _, err := mime.ParseMediaType(ct)
		if err != nil {
			slog.Debug("og: malformed Content-Type header", "url", urlStr, "contentType", ct)
			return nil, fmt.Errorf("%w malformed Content-Type header: %w", ErrOgHandled, err)
		}

		if mediaType != "text/html" && mediaType != "application/xhtml+xml" {
			slog.Debug("og: unsupported Content-Type", "url", urlStr, "contentType", mediaType)
			return nil, fmt.Errorf("%w unsupported Content-Type: %s", ErrOgHandled, mediaType)
		}
	}

	resp.Body = http.MaxBytesReader(nil, resp.Body, maxContentLength)

	doc, err := html.Parse(resp.Body)
	if err != nil {
		// Check if the error is specifically because the limit was exceeded
		var maxBytesErr *http.MaxBytesError
		if errors.As(err, &maxBytesErr) {
			slog.Debug("og: content exceeded max length", "url", urlStr, "limit", maxContentLength)
			return nil, fmt.Errorf("content too large: exceeded %d bytes", maxContentLength)
		}
		return nil, fmt.Errorf("failed to parse HTML: %w", err)
	}

	return doc, nil
}

package policy

import (
	"fmt"
	"net/http"

	"github.com/TecharoHQ/anubis/internal"
	"github.com/TecharoHQ/anubis/lib/policy/config"
	"github.com/TecharoHQ/anubis/lib/policy/expressions"
	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/common/types"
)

type CELChecker struct {
	program cel.Program
	src     string
}

func NewCELChecker(cfg *config.ExpressionOrList) (*CELChecker, error) {
	env, err := expressions.BotEnvironment()
	if err != nil {
		return nil, err
	}

	program, err := expressions.Compile(env, cfg.String())
	if err != nil {
		return nil, fmt.Errorf("can't compile CEL program: %w", err)
	}

	return &CELChecker{
		src:     cfg.String(),
		program: program,
	}, nil
}

func (cc *CELChecker) Hash() string {
	return internal.FastHash(cc.src)
}

func (cc *CELChecker) Check(r *http.Request) (bool, error) {
	result, _, err := cc.program.ContextEval(r.Context(), &CELRequest{r})

	if err != nil {
		return false, err
	}

	if val, ok := result.(types.Bool); ok {
		return bool(val), nil
	}

	return false, nil
}

type CELRequest struct {
	*http.Request
}

func (cr *CELRequest) Parent() cel.Activation { return nil }

func (cr *CELRequest) ResolveName(name string) (any, bool) {
	switch name {
	case "remoteAddress":
		return cr.Header.Get("X-Real-Ip"), true
	case "host":
		return cr.Host, true
	case "method":
		return cr.Method, true
	case "userAgent":
		return cr.UserAgent(), true
	case "path":
		return cr.URL.Path, true
	case "query":
		return expressions.URLValues{Values: cr.URL.Query()}, true
	case "headers":
		return expressions.HTTPHeaders{Header: cr.Header}, true
	case "load_1m":
		return expressions.Load1(), true
	case "load_5m":
		return expressions.Load5(), true
	case "load_15m":
		return expressions.Load15(), true
	default:
		return nil, false
	}
}

package localization

import (
	"embed"
	"encoding/json"
	"net/http"
	"strings"
	"sync"

	"github.com/TecharoHQ/anubis"
	"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})
}

// Get the language that is used by the localizer by retrieving a well-known string that is required to be present
func (sl *SimpleLocalizer) GetLang() string {
	_, tag, err := sl.Localizer.LocalizeWithTag(&i18n.LocalizeConfig{MessageID: "loading"})
	if err != nil {
		return "en"
	}
	return tag.String()
}

// GetLocalizer creates a localizer based on the request's Accept-Language header or forcedLanguage option
func GetLocalizer(r *http.Request) *SimpleLocalizer {
	var localizer *i18n.Localizer
	if anubis.ForcedLanguage == "" {
		localizer = NewLocalizationService().GetLocalizerFromRequest(r)
	} else {
		localizer = NewLocalizationService().GetLocalizer(anubis.ForcedLanguage)
	}
	return &SimpleLocalizer{Localizer: localizer}
}

package config

import (
	"errors"
	"fmt"

	"github.com/TecharoHQ/anubis"
)

var (
	ErrNoThresholdRulesDefined             = errors.New("config: no thresholds defined")
	ErrThresholdMustHaveName               = errors.New("config.Threshold: must set name")
	ErrThresholdMustHaveExpression         = errors.New("config.Threshold: must set expression")
	ErrThresholdChallengeMustHaveChallenge = errors.New("config.Threshold: a threshold with the CHALLENGE action must have challenge set")
	ErrThresholdCannotHaveWeighAction      = errors.New("config.Threshold: a threshold cannot have the WEIGH action")

	DefaultThresholds = []Threshold{
		{
			Name: "legacy-anubis-behaviour",
			Expression: &ExpressionOrList{
				Expression: "weight > 0",
			},
			Action: RuleChallenge,
			Challenge: &ChallengeRules{
				Algorithm:  "fast",
				Difficulty: anubis.DefaultDifficulty,
				ReportAs:   anubis.DefaultDifficulty,
			},
		},
	}
)

type Threshold struct {
	Name       string            `json:"name" yaml:"name"`
	Expression *ExpressionOrList `json:"expression" yaml:"expression"`
	Action     Rule              `json:"action" yaml:"action"`
	Challenge  *ChallengeRules   `json:"challenge" yaml:"challenge"`
}

func (t Threshold) Valid() error {
	var errs []error

	if len(t.Name) == 0 {
		errs = append(errs, ErrThresholdMustHaveName)
	}

	if t.Expression == nil {
		errs = append(errs, ErrThresholdMustHaveExpression)
	}

	if t.Expression != nil {
		if err := t.Expression.Valid(); err != nil {
			errs = append(errs, err)
		}
	}

	if err := t.Action.Valid(); err != nil {
		errs = append(errs, err)
	}

	if t.Action == RuleWeigh {
		errs = append(errs, ErrThresholdCannotHaveWeighAction)
	}

	if t.Action == RuleChallenge && t.Challenge == nil {
		errs = append(errs, ErrThresholdChallengeMustHaveChallenge)
	}

	if t.Challenge != nil {
		if err := t.Challenge.Valid(); err != nil {
			errs = append(errs, err)
		}
	}

	if len(errs) != 0 {
		return fmt.Errorf("config: threshold entry for %q is not valid:\n%w", t.Name, errors.Join(errs...))
	}

	return nil
}

package bbolt

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"time"

	"github.com/TecharoHQ/anubis/lib/store"
	"go.etcd.io/bbolt"
)

// Sentinel error values used for testing and in admin-visible error messages.
var (
	ErrBucketDoesNotExist = errors.New("bbolt: bucket does not exist")
	ErrNotExists          = errors.New("bbolt: value does not exist in store")
)

// Store implements store.Interface backed by bbolt[1].
//
// In essence, bbolt is a hierarchical key/value store with a twist: every value
// needs to belong to a bucket. Buckets can contain an infinite number of
// buckets. As such, Anubis nests values in buckets. Each value in the store
// is given its own bucket with two keys:
//
// 1. data - The raw data, usually in JSON
// 2. expiry - The expiry time formatted as a time.RFC3339Nano timestamp string
//
// When Anubis stores a new bit of data, it creates a new bucket for that value.
// This allows the cleanup phase to iterate over every bucket in the database and
// only scan the expiry times without having to decode the entire record.
//
// bbolt is not suitable for environments where multiple instance of Anubis need
// to read from and write to the same backend store. For that, use the valkey
// storage backend.
//
// [1]: https://github.com/etcd-io/bbolt
type Store struct {
	bdb *bbolt.DB
}

// Delete a key from the datastore. If the key does not exist, return an error.
func (s *Store) Delete(ctx context.Context, key string) error {
	return s.bdb.Update(func(tx *bbolt.Tx) error {
		if tx.Bucket([]byte(key)) == nil {
			return fmt.Errorf("%w: %q", ErrNotExists, key)
		}

		return tx.DeleteBucket([]byte(key))
	})
}

// Get a value from the datastore.
//
// Because each value is stored in its own bucket with data and expiry keys,
// two get operations are required:
//
// 1. Get the expiry key, parse as time.RFC3339Nano. If the key has expired, run deletion in the background and return a "key not found" error.
// 2. Get the data key, copy into the result byteslice, return it.
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
	var result []byte

	if err := s.bdb.View(func(tx *bbolt.Tx) error {
		itemBucket := tx.Bucket([]byte(key))
		if itemBucket == nil {
			return fmt.Errorf("%w: %q", store.ErrNotFound, key)
		}

		expiryStr := itemBucket.Get([]byte("expiry"))
		if expiryStr == nil {
			return fmt.Errorf("[unexpected] %w: %q (expiry is nil)", store.ErrNotFound, key)
		}

		expiry, err := time.Parse(time.RFC3339Nano, string(expiryStr))
		if err != nil {
			return fmt.Errorf("[unexpected] %w: %w", store.ErrCantDecode, err)
		}

		if time.Now().After(expiry) {
			go s.Delete(context.Background(), key)
			return fmt.Errorf("%w: %q", store.ErrNotFound, key)
		}

		dataStr := itemBucket.Get([]byte("data"))
		if dataStr == nil {
			return fmt.Errorf("[unexpected] %w: %q (data is nil)", store.ErrNotFound, key)
		}

		result = make([]byte, len(dataStr))
		if n := copy(result, dataStr); n != len(dataStr) {
			return fmt.Errorf("[unexpected] %w: %d bytes copied of %d", store.ErrCantDecode, n, len(dataStr))
		}

		return nil
	}); err != nil {
		return nil, err
	}

	return result, nil
}

// Set a value into the store with a given expiry.
func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
	expires := time.Now().Add(expiry)

	return s.bdb.Update(func(tx *bbolt.Tx) error {
		valueBkt, err := tx.CreateBucketIfNotExists([]byte(key))
		if err != nil {
			return fmt.Errorf("%w: %w: %q (create bucket)", store.ErrCantEncode, err, key)
		}

		if err := valueBkt.Put([]byte("expiry"), []byte(expires.Format(time.RFC3339Nano))); err != nil {
			return fmt.Errorf("%w: %q (expiry)", store.ErrCantEncode, key)
		}

		if err := valueBkt.Put([]byte("data"), value); err != nil {
			return fmt.Errorf("%w: %q (data)", store.ErrCantEncode, key)
		}

		return nil
	})
}

func (s *Store) cleanup(ctx context.Context) error {
	now := time.Now()

	return s.bdb.Update(func(tx *bbolt.Tx) error {
		return tx.ForEach(func(key []byte, valueBkt *bbolt.Bucket) error {
			var expiry time.Time
			var err error

			expiryStr := valueBkt.Get([]byte("expiry"))
			if expiryStr == nil {
				slog.Warn("while running cleanup, expiry is not set somehow, file a bug?", "key", string(key))
				return nil
			}

			expiry, err = time.Parse(time.RFC3339Nano, string(expiryStr))
			if err != nil {
				return fmt.Errorf("[unexpected] %w in bucket %q: %w", store.ErrCantDecode, string(key), err)
			}

			if now.After(expiry) {
				return tx.DeleteBucket(key)
			}

			return nil
		})
	})
}

func (s *Store) cleanupThread(ctx context.Context) {
	t := time.NewTicker(time.Hour)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			if err := s.cleanup(ctx); err != nil {
				slog.Error("error during bbolt cleanup", "err", err)
			}
		}
	}
}

package internal

import (
	"fmt"
	"log"
	"log/slog"
	"net/http"
	"os"
	"strings"
)

func InitSlog(level string) {
	var programLevel slog.Level
	if err := (&programLevel).UnmarshalText([]byte(level)); err != nil {
		fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", level, err)
		programLevel = slog.LevelInfo
	}

	leveler := &slog.LevelVar{}
	leveler.Set(programLevel)

	h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
		AddSource: true,
		Level:     leveler,
	})
	slog.SetDefault(slog.New(h))
}

func GetRequestLogger(r *http.Request) *slog.Logger {
	return slog.With(
		"user_agent", r.UserAgent(),
		"accept_language", r.Header.Get("Accept-Language"),
		"priority", r.Header.Get("Priority"),
		"x-forwarded-for",
		r.Header.Get("X-Forwarded-For"),
		"x-real-ip", r.Header.Get("X-Real-Ip"),
	)
}

// ErrorLogFilter is used to suppress "context canceled" logs from the http server when a request is canceled (e.g., when a client disconnects).
type ErrorLogFilter struct {
	Unwrap *log.Logger
}

func (elf *ErrorLogFilter) Write(p []byte) (n int, err error) {
	logMessage := string(p)
	if strings.Contains(logMessage, "context canceled") {
		return len(p), nil // Suppress the log by doing nothing
	}
	if elf.Unwrap != nil {
		return elf.Unwrap.Writer().Write(p)
	}
	return len(p), nil
}

func GetFilteredHTTPLogger() *log.Logger {
	stdErrLogger := log.New(os.Stderr, "", log.LstdFlags) // essentially what the default logger is.
	return log.New(&ErrorLogFilter{Unwrap: stdErrLogger}, "", 0)
}

package internal

import (
	"compress/gzip"
	"net/http"
	"strings"
)

func GzipMiddleware(level int, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
			next.ServeHTTP(w, r)
			return
		}

		w.Header().Set("Content-Encoding", "gzip")
		gz, err := gzip.NewWriterLevel(w, level)
		if err != nil {
			panic(err)
		}
		defer gz.Close()

		grw := gzipResponseWriter{ResponseWriter: w, sink: gz}
		next.ServeHTTP(grw, r)
	})
}

type gzipResponseWriter struct {
	http.ResponseWriter
	sink *gzip.Writer
}

func (w gzipResponseWriter) Write(b []byte) (int, error) {
	return w.sink.Write(b)
}

package valkey

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"

	"github.com/TecharoHQ/anubis/lib/store"
	valkey "github.com/redis/go-redis/v9"
)

var (
	ErrNoURL  = errors.New("valkey.Config: no URL defined")
	ErrBadURL = errors.New("valkey.Config: URL is invalid")
)

func init() {
	store.Register("valkey", Factory{})
}

type Factory struct{}

func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {
	var config Config

	if err := json.Unmarshal([]byte(data), &config); err != nil {
		return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	if err := config.Valid(); err != nil {
		return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	opts, err := valkey.ParseURL(config.URL)
	if err != nil {
		return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	rdb := valkey.NewClient(opts)

	if _, err := rdb.Ping(ctx).Result(); err != nil {
		return nil, fmt.Errorf("can't ping valkey instance: %w", err)
	}

	return &Store{
		rdb: rdb,
	}, nil
}

func (Factory) Valid(data json.RawMessage) error {
	var config Config
	if err := json.Unmarshal([]byte(data), &config); err != nil {
		return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	if err := config.Valid(); err != nil {
		return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	return nil
}

type Config struct {
	URL string `json:"url"`
}

func (c Config) Valid() error {
	var errs []error

	if c.URL == "" {
		errs = append(errs, ErrNoURL)
	}

	if _, err := valkey.ParseURL(c.URL); err != nil {
		errs = append(errs, ErrBadURL)
	}

	if len(errs) != 0 {
		return fmt.Errorf("valkey.Config: invalid config: %w", errors.Join(errs...))
	}

	return nil
}

package dnsbl

import (
	"errors"
	"fmt"
	"net"
	"strings"
)

//go:generate go tool golang.org/x/tools/cmd/stringer -type=DroneBLResponse

type DroneBLResponse byte

const (
	AllGood               DroneBLResponse = 0
	IRCDrone              DroneBLResponse = 3
	Bottler               DroneBLResponse = 5
	UnknownSpambotOrDrone DroneBLResponse = 6
	DDOSDrone             DroneBLResponse = 7
	SOCKSProxy            DroneBLResponse = 8
	HTTPProxy             DroneBLResponse = 9
	ProxyChain            DroneBLResponse = 10
	OpenProxy             DroneBLResponse = 11
	OpenDNSResolver       DroneBLResponse = 12
	BruteForceAttackers   DroneBLResponse = 13
	OpenWingateProxy      DroneBLResponse = 14
	CompromisedRouter     DroneBLResponse = 15
	AutoRootingWorms      DroneBLResponse = 16
	AutoDetectedBotIP     DroneBLResponse = 17
	Unknown               DroneBLResponse = 255
)

func Reverse(ip net.IP) string {
	if ip.To4() != nil {
		return reverse4(ip)
	}

	return reverse6(ip)
}

func reverse4(ip net.IP) string {
	splitAddress := strings.Split(ip.String(), ".")

	// swap first and last octet
	splitAddress[0], splitAddress[3] = splitAddress[3], splitAddress[0]
	// swap middle octets
	splitAddress[1], splitAddress[2] = splitAddress[2], splitAddress[1]

	return strings.Join(splitAddress, ".")
}

func reverse6(ip net.IP) string {
	ipBytes := []byte(ip)
	var sb strings.Builder

	for i := len(ipBytes) - 1; i >= 0; i-- {
		// Split the byte into two nibbles
		highNibble := ipBytes[i] >> 4
		lowNibble := ipBytes[i] & 0x0F

		// Append the nibbles in reversed order
		sb.WriteString(fmt.Sprintf("%x.%x.", lowNibble, highNibble))
	}

	return sb.String()[:len(sb.String())-1]
}

func Lookup(ipStr string) (DroneBLResponse, error) {
	ip := net.ParseIP(ipStr)
	if ip == nil {
		return Unknown, errors.New("dnsbl: input is not an IP address")
	}

	revIP := Reverse(ip) + ".dnsbl.dronebl.org"

	ips, err := net.LookupIP(revIP)
	if err != nil {
		var dnserr *net.DNSError
		if errors.As(err, &dnserr) {
			if dnserr.IsNotFound {
				return AllGood, nil
			}
		}

		return Unknown, err
	}

	if len(ips) != 0 {
		for _, ip := range ips {
			return DroneBLResponse(ip.To4()[3]), nil
		}
	}

	return UnknownSpambotOrDrone, nil
}

package thoth

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/netip"

	iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
	"github.com/gaissmai/bart"
	"google.golang.org/grpc"
)

type IPToASNWithCache struct {
	next  iptoasnv1.IpToASNServiceClient
	table *bart.Table[*iptoasnv1.LookupResponse]
}

func NewIpToASNWithCache(next iptoasnv1.IpToASNServiceClient) *IPToASNWithCache {
	result := &IPToASNWithCache{
		next:  next,
		table: &bart.Table[*iptoasnv1.LookupResponse]{},
	}

	for _, pfx := range []netip.Prefix{
		netip.MustParsePrefix("10.0.0.0/8"),         // RFC 1918
		netip.MustParsePrefix("172.16.0.0/12"),      // RFC 1918
		netip.MustParsePrefix("192.168.0.0/16"),     // RFC 1918
		netip.MustParsePrefix("127.0.0.0/8"),        // Loopback
		netip.MustParsePrefix("169.254.0.0/16"),     // Link-local
		netip.MustParsePrefix("100.64.0.0/10"),      // CGNAT
		netip.MustParsePrefix("192.0.0.0/24"),       // Protocol assignments
		netip.MustParsePrefix("192.0.2.0/24"),       // TEST-NET-1
		netip.MustParsePrefix("198.18.0.0/15"),      // Benchmarking
		netip.MustParsePrefix("198.51.100.0/24"),    // TEST-NET-2
		netip.MustParsePrefix("203.0.113.0/24"),     // TEST-NET-3
		netip.MustParsePrefix("240.0.0.0/4"),        // Reserved
		netip.MustParsePrefix("255.255.255.255/32"), // Broadcast
		netip.MustParsePrefix("fc00::/7"),           // Unique local address
		netip.MustParsePrefix("fe80::/10"),          // Link-local
		netip.MustParsePrefix("::1/128"),            // Loopback
		netip.MustParsePrefix("::/128"),             // Unspecified
		netip.MustParsePrefix("100::/64"),           // Discard-only
		netip.MustParsePrefix("2001:db8::/32"),      // Documentation
	} {
		result.table.Insert(pfx, &iptoasnv1.LookupResponse{Announced: false})
	}

	return result
}

func (ip2asn *IPToASNWithCache) Lookup(ctx context.Context, lr *iptoasnv1.LookupRequest, opts ...grpc.CallOption) (*iptoasnv1.LookupResponse, error) {
	addr, err := netip.ParseAddr(lr.GetIpAddress())
	if err != nil {
		return nil, fmt.Errorf("input is not an IP address: %w", err)
	}

	cachedResponse, ok := ip2asn.table.Lookup(addr)
	if ok {
		return cachedResponse, nil
	}

	resp, err := ip2asn.next.Lookup(ctx, lr, opts...)
	if err != nil {
		return nil, err
	}

	var errs []error
	for _, cidr := range resp.GetCidr() {
		pfx, err := netip.ParsePrefix(cidr)
		if err != nil {
			errs = append(errs, err)
			continue
		}
		ip2asn.table.Insert(pfx, resp)
	}

	if len(errs) != 0 {
		slog.Error("errors parsing IP prefixes", "err", errors.Join(errs...))
	}

	return resp, nil
}

package bbolt

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/TecharoHQ/anubis/lib/store"
	"go.etcd.io/bbolt"
)

var (
	ErrMissingPath     = errors.New("bbolt: path is missing from config")
	ErrCantWriteToPath = errors.New("bbolt: can't write to path")
)

func init() {
	store.Register("bbolt", Factory{})
}

// Factory builds new instances of the bbolt storage backend according to
// configuration passed via a json.RawMessage.
type Factory struct{}

// Build parses and validates the bbolt storage backend Config and creates
// a new instance of it.
func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {
	var config Config
	if err := json.Unmarshal([]byte(data), &config); err != nil {
		return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	if err := config.Valid(); err != nil {
		return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	bdb, err := bbolt.Open(config.Path, 0600, nil)
	if err != nil {
		return nil, fmt.Errorf("can't open bbolt database %s: %w", config.Path, err)
	}

	result := &Store{
		bdb: bdb,
	}

	go result.cleanupThread(ctx)

	return result, nil
}

// Valid parses and validates the bbolt store Config or returns
// an error.
func (Factory) Valid(data json.RawMessage) error {
	var config Config
	if err := json.Unmarshal([]byte(data), &config); err != nil {
		return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	if err := config.Valid(); err != nil {
		return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
	}

	return nil
}

// Config is the bbolt storage backend configuration.
type Config struct {
	// Path is the filesystem path of the database. The folder must be writable to Anubis.
	Path string `json:"path"`
}

// Valid validates the configuration including checking if its containing folder is writable.
func (c Config) Valid() error {
	var errs []error

	if c.Path == "" {
		errs = append(errs, ErrMissingPath)
	} else {
		dir := filepath.Dir(c.Path)
		if err := os.WriteFile(filepath.Join(dir, ".test-file"), []byte(""), 0600); err != nil {
			errs = append(errs, ErrCantWriteToPath)
		}
		os.Remove(filepath.Join(dir, ".test-file"))
	}

	if len(errs) != 0 {
		return errors.Join(errs...)
	}

	return nil
}

package valkey

import (
	"context"
	"fmt"
	"time"

	"github.com/TecharoHQ/anubis/lib/store"
	valkey "github.com/redis/go-redis/v9"
)

type Store struct {
	rdb *valkey.Client
}

func (s *Store) Delete(ctx context.Context, key string) error {
	n, err := s.rdb.Del(ctx, key).Result()
	if err != nil {
		return fmt.Errorf("can't delete from valkey: %w", err)
	}

	switch n {
	case 0:
		return fmt.Errorf("%w: %d key(s) deleted", store.ErrNotFound, n)
	default:
		return nil
	}
}

func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
	result, err := s.rdb.Get(ctx, key).Result()
	if err != nil {
		if valkey.HasErrorPrefix(err, "redis: nil") {
			return nil, fmt.Errorf("%w: %w", store.ErrNotFound, err)
		}

		return nil, fmt.Errorf("can't fetch from valkey: %w", err)
	}

	return []byte(result), nil
}

func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
	if _, err := s.rdb.Set(ctx, key, string(value), expiry).Result(); err != nil {
		return fmt.Errorf("can't set %q in valkey: %w", key, err)
	}

	return nil
}

package config

import (
	"encoding/json"
	"errors"
	"fmt"

	"github.com/TecharoHQ/anubis/lib/store"
	_ "github.com/TecharoHQ/anubis/lib/store/all"
)

var (
	ErrNoStoreBackend      = errors.New("config.Store: no backend defined")
	ErrUnknownStoreBackend = errors.New("config.Store: unknown backend")
)

type Store struct {
	Backend    string          `json:"backend"`
	Parameters json.RawMessage `json:"parameters"`
}

func (s *Store) Valid() error {
	var errs []error

	if len(s.Backend) == 0 {
		errs = append(errs, ErrNoStoreBackend)
	}

	fac, ok := store.Get(s.Backend)
	switch ok {
	case true:
		if err := fac.Valid(s.Parameters); err != nil {
			errs = append(errs, err)
		}
	case false:
		errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownStoreBackend, s.Backend))
	}

	if len(errs) != 0 {
		return errors.Join(errs...)
	}

	return nil
}

package thoth

import (
	"context"
	"crypto/tls"
	"fmt"
	"time"

	"github.com/TecharoHQ/anubis"
	iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
	grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
	"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/timeout"
	"github.com/prometheus/client_golang/prometheus"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/credentials/insecure"
	healthv1 "google.golang.org/grpc/health/grpc_health_v1"
)

type Client struct {
	conn    *grpc.ClientConn
	health  healthv1.HealthClient
	IPToASN iptoasnv1.IpToASNServiceClient
}

func New(ctx context.Context, thothURL, apiToken string, plaintext bool) (*Client, error) {
	clMetrics := grpcprom.NewClientMetrics(
		grpcprom.WithClientHandlingTimeHistogram(
			grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}),
		),
	)
	prometheus.DefaultRegisterer.Register(clMetrics)

	do := []grpc.DialOption{
		grpc.WithChainUnaryInterceptor(
			timeout.UnaryClientInterceptor(500*time.Millisecond),
			clMetrics.UnaryClientInterceptor(),
			authUnaryClientInterceptor(apiToken),
		),
		grpc.WithChainStreamInterceptor(
			clMetrics.StreamClientInterceptor(),
			authStreamClientInterceptor(apiToken),
		),
		grpc.WithUserAgent(fmt.Sprint("Techaro/anubis:", anubis.Version)),
	}

	if plaintext {
		do = append(do, grpc.WithTransportCredentials(insecure.NewCredentials()))
	} else {
		do = append(do, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
	}

	conn, err := grpc.NewClient(
		thothURL,
		do...,
	)
	if err != nil {
		return nil, fmt.Errorf("can't dial thoth at %s: %w", thothURL, err)
	}

	hc := healthv1.NewHealthClient(conn)

	return &Client{
		conn:    conn,
		health:  hc,
		IPToASN: NewIpToASNWithCache(iptoasnv1.NewIpToASNServiceClient(conn)),
	}, nil
}

func (c *Client) Close() error {
	if c.conn != nil {
		return c.conn.Close()
	}
	return nil
}

func (c *Client) WithIPToASNService(impl iptoasnv1.IpToASNServiceClient) {
	c.IPToASN = impl
}

// Code generated by templ - DO NOT EDIT.

// templ: version: v0.3.906
package metarefresh

//lint:file-ignore SA4006 This context is only used if a nested component is present.

import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"

import (
	"fmt"

	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/lib/localization"
)

func page(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 {
			return templ_7745c5c3_CtxErr
		}
		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
		if !templ_7745c5c3_IsBuffer {
			defer func() {
				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
				if templ_7745c5c3_Err == nil {
					templ_7745c5c3_Err = templ_7745c5c3_BufErr
				}
			}()
		}
		ctx = templ.InitializeContext(ctx)
		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
		if templ_7745c5c3_Var1 == nil {
			templ_7745c5c3_Var1 = templ.NopComponent
		}
		ctx = templ.ClearChildren(ctx)
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
<img id=\"image\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		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: 12, Col: 165}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"> <img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		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: 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, "\">
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var4 string
		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: 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, "

")
		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, "
<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, "\">
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		return nil
	})
}

var _ = templruntime.GeneratedTemplate

// Code generated by templ - DO NOT EDIT.

// templ: version: v0.3.906
package web

//lint:file-ignore SA4006 This context is only used if a nested component is present.

import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"

import (
	"fmt"
	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/lib/localization"
	"github.com/TecharoHQ/anubis/lib/policy/config"
	"github.com/TecharoHQ/anubis/xess"
)

func base(title string, body templ.Component, impressum *config.Impressum, challenge any, ogTags map[string]string, localizer *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 {
			return templ_7745c5c3_CtxErr
		}
		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
		if !templ_7745c5c3_IsBuffer {
			defer func() {
				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
				if templ_7745c5c3_Err == nil {
					templ_7745c5c3_Err = templ_7745c5c3_BufErr
				}
			}()
		}
		ctx = templ.InitializeContext(ctx)
		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
		if templ_7745c5c3_Var1 == nil {
			templ_7745c5c3_Var1 = templ.NopComponent
		}
		ctx = templ.ClearChildren(ctx)
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var2 string
		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.GetLang())
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 13, Col: 33}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><title>")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var3 string
		templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 15, Col: 17}
		}
		_, 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, "</title><link rel=\"stylesheet\" href=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var4 string
		templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + xess.URL)
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 16, Col: 61}
		}
		_, 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, "\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta name=\"robots\" content=\"noindex,nofollow\">")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		for key, value := range ogTags {
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<meta property=\"")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var5 string
			templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(key)
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 20, Col: 24}
			}
			_, 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, 6, "\" content=\"")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var6 string
			templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(value)
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 20, Col: 42}
			}
			_, 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, 7, "\">")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templ.JSONScript("anubis_version", anubis.Version).Render(ctx, templ_7745c5c3_Buffer)
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		if challenge != nil {
			templ_7745c5c3_Err = templ.JSONScript("anubis_challenge", challenge).Render(ctx, templ_7745c5c3_Buffer)
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
		}
		templ_7745c5c3_Err = templ.JSONScript("anubis_base_prefix", anubis.BasePrefix).Render(ctx, templ_7745c5c3_Buffer)
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var7 string
		templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 68, Col: 47}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var8 string
		templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("protected_by"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 73, Col: 36}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " Anubis ")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var9 string
		templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("protected_from"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 73, Col: 127}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " Techaro. ")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var10 string
		templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("made_with"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 75, Col: 40}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ".

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var11 string
		templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("mascot_design"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 77, Col: 39}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var12 string
		templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("celphase"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 77, Col: 123}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, ".
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		if impressum != nil {
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templ.Raw(impressum.Footer).Render(ctx, templ_7745c5c3_Buffer)
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "-- <a href=\"")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var13 templ.SafeURL
			templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("%simprint", anubis.APIPrefix)))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 81, Col: 78}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">Imprint
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		return nil
	})
}

func index(localizer *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 {
			return templ_7745c5c3_CtxErr
		}
		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
		if !templ_7745c5c3_IsBuffer {
			defer func() {
				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
				if templ_7745c5c3_Err == nil {
					templ_7745c5c3_Err = templ_7745c5c3_BufErr
				}
			}()
		}
		ctx = templ.InitializeContext(ctx)
		templ_7745c5c3_Var14 := templ.GetChildren(ctx)
		if templ_7745c5c3_Var14 == nil {
			templ_7745c5c3_Var14 = templ.NopComponent
		}
		ctx = templ.ClearChildren(ctx)
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
<img id=\"image\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var15 string
		templ_7745c5c3_Var15, 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: `index.templ`, Line: 93, Col: 165}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"> <img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var16 string
		templ_7745c5c3_Var16, 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: `index.templ`, Line: 94, Col: 174}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var17 string
		templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("loading"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 95, Col: 41}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var19 string
		templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("why_am_i_seeing"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 101, Col: 44}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var20 string
		templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 103, Col: 45}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var21 string
		templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 106, Col: 38}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var22 string
		templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 109, Col: 33}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var23 string
		templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 112, Col: 34}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var24 string
		templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("version_info"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 114, Col: 35}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var25 string
		templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.Version)
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 114, Col: 60}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ".

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var26 string
		templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("javascript_required"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 118, Col: 40}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		return nil
	})
}

func errorPage(message, mail string, localizer *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 {
			return templ_7745c5c3_CtxErr
		}
		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
		if !templ_7745c5c3_IsBuffer {
			defer func() {
				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
				if templ_7745c5c3_Err == nil {
					templ_7745c5c3_Err = templ_7745c5c3_BufErr
				}
			}()
		}
		ctx = templ.InitializeContext(ctx)
		templ_7745c5c3_Var27 := templ.GetChildren(ctx)
		if templ_7745c5c3_Var27 == nil {
			templ_7745c5c3_Var27 = templ.NopComponent
		}
		ctx = templ.ClearChildren(ctx)
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
<img id=\"image\" alt=\"Sad Anubis\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var28 string
		templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/reject.webp?cacheBuster=" + anubis.Version)
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 127, Col: 181}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var29 string
		templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(message)
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 128, Col: 14}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ".
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		if mail != "" {
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var30 string
			templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("go_home"))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 131, Col: 40}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var31 string
			templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("contact_webmaster"))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 131, Col: 81}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " <a href=\"")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var32 templ.SafeURL
			templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs("mailto:" + templ.SafeURL(mail))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 132, Col: 45}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var33 string
			templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(mail)
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 133, Col: 11}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
		} else {
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var34 string
			templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("go_home"))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 137, Col: 42}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		return nil
	})
}

func StaticHappy(localizer *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 {
			return templ_7745c5c3_CtxErr
		}
		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
		if !templ_7745c5c3_IsBuffer {
			defer func() {
				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
				if templ_7745c5c3_Err == nil {
					templ_7745c5c3_Err = templ_7745c5c3_BufErr
				}
			}()
		}
		ctx = templ.InitializeContext(ctx)
		templ_7745c5c3_Var35 := templ.GetChildren(ctx)
		if templ_7745c5c3_Var35 == nil {
			templ_7745c5c3_Var35 = templ.NopComponent
		}
		ctx = templ.ClearChildren(ctx)
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
<img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var36 string
		templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs("/.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: `index.templ`, Line: 148, Col: 18}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var37 string
		templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("static_check_endpoint"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 150, Col: 43}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		return nil
	})
}

func bench(localizer *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 {
			return templ_7745c5c3_CtxErr
		}
		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
		if !templ_7745c5c3_IsBuffer {
			defer func() {
				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
				if templ_7745c5c3_Err == nil {
					templ_7745c5c3_Err = templ_7745c5c3_BufErr
				}
			}()
		}
		ctx = templ.InitializeContext(ctx)
		templ_7745c5c3_Var38 := templ.GetChildren(ctx)
		if templ_7745c5c3_Var38 == nil {
			templ_7745c5c3_Var38 = templ.NopComponent
		}
		ctx = templ.ClearChildren(ctx)
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var39 string
		templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("time"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 161, Col: 51}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "	")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var40 string
		templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("iters"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 162, Col: 50}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var41 string
		templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("time_a"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 165, Col: 53}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "	")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var42 string
		templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("iters_a"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 166, Col: 52}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "	")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var43 string
		templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("time_b"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 167, Col: 53}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "	")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var44 string
		templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("iters_b"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 168, Col: 52}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

<img id=\"image\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var45 string
		templ_7745c5c3_Var45, 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: `index.templ`, Line: 177, Col: 166}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var46 string
		templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("loading"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 178, Col: 66}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var48 string
		templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("benchmark_requires_js"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 182, Col: 45}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

<form id=\"controls\" style=\"position:fixed;top:0.5rem;right:0.5rem\">
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var49 string
		templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("difficulty"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 188, Col: 88}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " <input id=\"difficulty-input\" type=\"number\" name=\"difficulty\" style=\"width:3rem\">

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var50 string
		templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("algorithm"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 192, Col: 87}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " <select id=\"algorithm-select\" name=\"algorithm\"></select>

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var51 string
		templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("compare"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 196, Col: 83}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " <select id=\"compare-select\" name=\"compare\"><option value=\"NONE\">-</option></select>
</form>")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		return nil
	})
}

var _ = templruntime.GeneratedTemplate

package web

import (
	"github.com/a-h/templ"

	"github.com/TecharoHQ/anubis/lib/localization"
	"github.com/TecharoHQ/anubis/lib/policy/config"
)

func Base(title string, body templ.Component, impressum *config.Impressum, localizer *localization.SimpleLocalizer) templ.Component {
	return base(title, body, impressum, nil, nil, localizer)
}

func BaseWithChallengeAndOGTags(title string, body templ.Component, impressum *config.Impressum, challenge string, rules *config.ChallengeRules, ogTags map[string]string, localizer *localization.SimpleLocalizer) (templ.Component, error) {
	return base(title, body, impressum, struct {
		Rules     *config.ChallengeRules `json:"rules"`
		Challenge string                 `json:"challenge"`
	}{
		Challenge: challenge,
		Rules:     rules,
	}, ogTags, localizer), nil
}

func Index(localizer *localization.SimpleLocalizer) templ.Component {
	return index(localizer)
}

func ErrorPage(msg, mail string, localizer *localization.SimpleLocalizer) templ.Component {
	return errorPage(msg, mail, localizer)
}

func Bench(localizer *localization.SimpleLocalizer) templ.Component {
	return bench(localizer)
}

package expressions

import (
	"math/rand/v2"
	"strings"

	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/common/types"
	"github.com/google/cel-go/common/types/ref"
	"github.com/google/cel-go/common/types/traits"
	"github.com/google/cel-go/ext"
)

// BotEnvironment creates a new CEL environment, this is the set of
// variables and functions that are passed into the CEL scope so that
// Anubis can fail loudly and early when something is invalid instead
// of blowing up at runtime.
func BotEnvironment() (*cel.Env, error) {
	return New(
		// Variables exposed to CEL programs:
		cel.Variable("remoteAddress", cel.StringType),
		cel.Variable("host", cel.StringType),
		cel.Variable("method", cel.StringType),
		cel.Variable("userAgent", cel.StringType),
		cel.Variable("path", cel.StringType),
		cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
		cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
		cel.Variable("load_1m", cel.DoubleType),
		cel.Variable("load_5m", cel.DoubleType),
		cel.Variable("load_15m", cel.DoubleType),

		// Bot-specific functions:
		cel.Function("missingHeader",
			cel.Overload("missingHeader_map_string_string_string",
				[]*cel.Type{cel.MapType(cel.StringType, cel.StringType), cel.StringType},
				cel.BoolType,
				cel.BinaryBinding(func(headers, key ref.Val) ref.Val {
					// Convert headers to a trait that supports Find
					headersMap, ok := headers.(traits.Indexer)
					if !ok {
						return types.ValOrErr(headers, "headers is not a map, but is %T", headers)
					}

					keyStr, ok := key.(types.String)
					if !ok {
						return types.ValOrErr(key, "key is not a string, but is %T", key)
					}

					val := headersMap.Get(keyStr)
					// Check if the key is missing by testing for an error
					if types.IsError(val) {
						return types.Bool(true) // header is missing
					}
					return types.Bool(false) // header is present
				}),
			),
		),

		cel.Function("segments",
			cel.Overload("segments_string_list_string",
				[]*cel.Type{cel.StringType},
				cel.ListType(cel.StringType),
				cel.UnaryBinding(func(path ref.Val) ref.Val {
					pathStrType, ok := path.(types.String)
					if !ok {
						return types.ValOrErr(path, "path is not a string, but is %T", path)
					}

					pathStr := string(pathStrType)
					if !strings.HasPrefix(pathStr, "/") {
						return types.ValOrErr(path, "path does not start with /")
					}

					pathList := strings.Split(string(pathStr), "/")[1:]

					return types.NewStringList(types.DefaultTypeAdapter, pathList)
				}),
			),
		),
	)
}

// NewThreshold creates a new CEL environment for threshold checking.
func ThresholdEnvironment() (*cel.Env, error) {
	return New(
		cel.Variable("weight", cel.IntType),
	)
}

func New(opts ...cel.EnvOption) (*cel.Env, error) {
	args := []cel.EnvOption{
		ext.Strings(
			ext.StringsLocale("en_US"),
			ext.StringsValidateFormatCalls(true),
		),

		// default all timestamps to UTC
		cel.DefaultUTCTimeZone(true),

		// Functions exposed to all CEL programs:
		cel.Function("randInt",
			cel.Overload("randInt_int",
				[]*cel.Type{cel.IntType},
				cel.IntType,
				cel.UnaryBinding(func(val ref.Val) ref.Val {
					n, ok := val.(types.Int)
					if !ok {
						return types.ValOrErr(val, "value is not an integer, but is %T", val)
					}

					return types.Int(rand.IntN(int(n)))
				}),
			),
		),
	}

	args = append(args, opts...)
	return cel.NewEnv(args...)
}

// Compile takes CEL environment and syntax tree then emits an optimized
// Program for execution.
func Compile(env *cel.Env, src string) (cel.Program, error) {
	intermediate, iss := env.Compile(src)
	if iss != nil {
		return nil, iss.Err()
	}

	ast, iss := env.Check(intermediate)
	if iss != nil {
		return nil, iss.Err()
	}

	return env.Program(
		ast,
		cel.EvalOptions(
			// optimize regular expressions right now instead of on the fly
			cel.OptOptimize,
		),
	)
}

/*
Batch process robots.txt files from archives like https://github.com/nrjones8/robots-dot-txt-archive-bot/tree/master/data/cleaned
into Anubis CEL policies. Usage: go run batch_process.go <directory with robots.txt files>
*/
package main

import (
	"fmt"
	"io/fs"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("Usage: go run batch_process.go <cleaned_directory>")
		fmt.Println("Example: go run batch_process.go ./cleaned")
		os.Exit(1)
	}

	cleanedDir := os.Args[1]
	outputDir := "generated_policies"

	// Create output directory
	if err := os.MkdirAll(outputDir, 0755); err != nil {
		log.Fatalf("Failed to create output directory: %v", err)
	}

	count := 0
	err := filepath.WalkDir(cleanedDir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		// Skip directories
		if d.IsDir() {
			return nil
		}

		// Generate policy name from file path
		relPath, _ := filepath.Rel(cleanedDir, path)
		policyName := strings.ReplaceAll(relPath, "/", "-")
		policyName = strings.TrimSuffix(policyName, "-robots.txt")
		policyName = strings.ReplaceAll(policyName, ".", "-")

		outputFile := filepath.Join(outputDir, policyName+".yaml")

		cmd := exec.Command("go", "run", "main.go",
			"-input", path,
			"-output", outputFile,
			"-name", policyName,
			"-format", "yaml")

		if err := cmd.Run(); err != nil {
			fmt.Printf("Warning: Failed to process %s: %v\n", path, err)
			return nil // Continue processing other files
		}

		count++
		if count%100 == 0 {
			fmt.Printf("Processed %d files...\n", count)
		} else if count%10 == 0 {
			fmt.Print(".")
		}

		return nil
	})

	if err != nil {
		log.Fatalf("Error walking directory: %v", err)
	}

	fmt.Printf("Successfully processed %d robots.txt files\n", count)
	fmt.Printf("Generated policies saved to: %s/\n", outputDir)
}

package config

import (
	"encoding/json"
	"errors"
	"fmt"
	"slices"
	"strings"
)

var (
	ErrExpressionOrListMustBeStringOrObject = errors.New("config: this must be a string or an object")
	ErrExpressionEmpty                      = errors.New("config: this expression is empty")
	ErrExpressionCantHaveBoth               = errors.New("config: expression block can't contain multiple expression types")
)

type ExpressionOrList struct {
	Expression string   `json:"-" yaml:"-"`
	All        []string `json:"all,omitempty" yaml:"all,omitempty"`
	Any        []string `json:"any,omitempty" yaml:"any,omitempty"`
}

func (eol ExpressionOrList) String() string {
	switch {
	case len(eol.Expression) != 0:
		return eol.Expression
	case len(eol.All) != 0:
		var sb strings.Builder
		for i, pred := range eol.All {
			if i != 0 {
				fmt.Fprintf(&sb, " && ")
			}
			fmt.Fprintf(&sb, "( %s )", pred)
		}
		return sb.String()
	case len(eol.Any) != 0:
		var sb strings.Builder
		for i, pred := range eol.Any {
			if i != 0 {
				fmt.Fprintf(&sb, " || ")
			}
			fmt.Fprintf(&sb, "( %s )", pred)
		}
		return sb.String()
	}
	panic("this should not happen")
}

func (eol ExpressionOrList) Equal(rhs *ExpressionOrList) bool {
	if eol.Expression != rhs.Expression {
		return false
	}

	if !slices.Equal(eol.All, rhs.All) {
		return false
	}

	if !slices.Equal(eol.Any, rhs.Any) {
		return false
	}

	return true
}

func (eol *ExpressionOrList) MarshalYAML() (any, error) {
	switch {
	case len(eol.All) == 1 && len(eol.Any) == 0:
		eol.Expression = eol.All[0]
		eol.All = nil
	case len(eol.Any) == 1 && len(eol.All) == 0:
		eol.Expression = eol.Any[0]
		eol.Any = nil
	}

	if eol.Expression != "" {
		return eol.Expression, nil
	}

	type RawExpressionOrList ExpressionOrList
	return RawExpressionOrList(*eol), nil
}

func (eol *ExpressionOrList) MarshalJSON() ([]byte, error) {
	switch {
	case len(eol.All) == 1 && len(eol.Any) == 0:
		eol.Expression = eol.All[0]
		eol.All = nil
	case len(eol.Any) == 1 && len(eol.All) == 0:
		eol.Expression = eol.Any[0]
		eol.Any = nil
	}

	if eol.Expression != "" {
		return json.Marshal(string(eol.Expression))
	}

	type RawExpressionOrList ExpressionOrList
	val := RawExpressionOrList(*eol)
	return json.Marshal(val)
}

func (eol *ExpressionOrList) UnmarshalJSON(data []byte) error {
	switch string(data[0]) {
	case `"`: // string
		return json.Unmarshal(data, &eol.Expression)
	case "{": // object
		type RawExpressionOrList ExpressionOrList
		var val RawExpressionOrList
		if err := json.Unmarshal(data, &val); err != nil {
			return err
		}
		eol.All = val.All
		eol.Any = val.Any

		return nil
	}

	return ErrExpressionOrListMustBeStringOrObject
}

func (eol *ExpressionOrList) Valid() error {
	if eol.Expression == "" && len(eol.All) == 0 && len(eol.Any) == 0 {
		return ErrExpressionEmpty
	}
	if len(eol.All) != 0 && len(eol.Any) != 0 {
		return ErrExpressionCantHaveBoth
	}

	return nil
}

package ogtags

import (
	"context"
	"errors"
	"log/slog"
	"net/url"
	"syscall"
)

// GetOGTags is the main function that retrieves Open Graph tags for a URL
func (c *OGTagCache) GetOGTags(ctx context.Context, url *url.URL, originalHost string) (map[string]string, error) {
	if url == nil {
		return nil, errors.New("nil URL provided, cannot fetch OG tags")
	}

	if len(c.ogOverride) != 0 {
		return c.ogOverride, nil
	}

	target := c.getTarget(url)
	cacheKey := c.generateCacheKey(target, originalHost)

	// Check cache first
	if cachedTags := c.checkCache(ctx, cacheKey); cachedTags != nil {
		return cachedTags, nil
	}

	// Fetch HTML content, passing the original host
	doc, err := c.fetchHTMLDocumentWithCache(ctx, target, originalHost, cacheKey)
	if errors.Is(err, syscall.ECONNREFUSED) {
		slog.Debug("Connection refused, returning empty tags")
		return nil, nil
	} else if errors.Is(err, ErrOgHandled) {
		// Error was handled in fetchHTMLDocument, return empty tags
		return nil, nil
	}
	if err != nil {
		return nil, err
	}

	// Extract OG tags
	ogTags := c.extractOGTags(doc)

	// Store in cache
	c.cache.Set(ctx, cacheKey, ogTags, c.ogTimeToLive)

	return ogTags, nil
}

func (c *OGTagCache) generateCacheKey(target string, originalHost string) string {
	var cacheKey string

	if c.ogCacheConsiderHost {
		cacheKey = target + "|" + originalHost
	} else {
		cacheKey = target
	}
	return cacheKey
}

// checkCache checks if we have the tags cached and returns them if so
func (c *OGTagCache) checkCache(ctx context.Context, cacheKey string) map[string]string {
	if cachedTags, err := c.cache.Get(ctx, cacheKey); err == nil {
		slog.Debug("cache hit", "tags", cachedTags)
		return cachedTags
	}
	slog.Debug("cache miss", "url", cacheKey)
	return nil
}

package thothmock

import (
	"context"
	"net/netip"

	iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func MockIpToASNService() *IpToASNService {
	responses := map[string]*iptoasnv1.LookupResponse{
		"127.0.0.1": {Announced: false},
		"::1":       {Announced: false},
		"10.10.10.10": {
			Announced:   true,
			AsNumber:    13335,
			Cidr:        []string{"1.1.1.0/24"},
			CountryCode: "US",
			Description: "Cloudflare",
		},
		"2.2.2.2": {
			Announced:   true,
			AsNumber:    420,
			Cidr:        []string{"2.2.2.0/24"},
			CountryCode: "CA",
			Description: "test canada",
		},
		"1.1.1.1": {
			Announced:   true,
			AsNumber:    13335,
			Cidr:        []string{"1.1.1.0/24"},
			CountryCode: "US",
			Description: "Cloudflare",
		},
	}

	return &IpToASNService{Responses: responses}
}

type IpToASNService struct {
	iptoasnv1.UnimplementedIpToASNServiceServer
	Responses map[string]*iptoasnv1.LookupResponse
}

func (ip2asn *IpToASNService) Lookup(ctx context.Context, lr *iptoasnv1.LookupRequest, opts ...grpc.CallOption) (*iptoasnv1.LookupResponse, error) {
	if _, err := netip.ParseAddr(lr.GetIpAddress()); err != nil {
		return nil, err
	}

	resp, ok := ip2asn.Responses[lr.GetIpAddress()]
	if !ok {
		return nil, status.Error(codes.NotFound, "IP address not found in mock")
	}

	return resp, nil
}

package memory

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/TecharoHQ/anubis/decaymap"
	"github.com/TecharoHQ/anubis/lib/store"
)

type factory struct{}

func (factory) Build(ctx context.Context, _ json.RawMessage) (store.Interface, error) {
	return New(ctx), nil
}

func (factory) Valid(json.RawMessage) error { return nil }

func init() {
	store.Register("memory", factory{})
}

type impl struct {
	store *decaymap.Impl[string, []byte]
}

func (i *impl) Delete(_ context.Context, key string) error {
	if !i.store.Delete(key) {
		return fmt.Errorf("%w: %q", store.ErrNotFound, key)
	}

	return nil
}

func (i *impl) Get(_ context.Context, key string) ([]byte, error) {
	result, ok := i.store.Get(key)
	if !ok {
		return nil, fmt.Errorf("%w: %q", store.ErrNotFound, key)
	}

	return result, nil
}

func (i *impl) Set(_ context.Context, key string, value []byte, expiry time.Duration) error {
	i.store.Set(key, value, expiry)
	return nil
}

func (i *impl) cleanupThread(ctx context.Context) {
	t := time.NewTicker(5 * time.Minute)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			i.store.Cleanup()
		}
	}
}

// New creates a simple in-memory store. This will not scale to multiple Anubis instances.
func New(ctx context.Context) store.Interface {
	result := &impl{
		store: decaymap.New[string, []byte](),
	}

	go result.cleanupThread(ctx)

	return result
}

package config

import (
	"errors"
	"fmt"
	"time"
)

var (
	ErrInvalidOpenGraphConfig   = errors.New("config.OpenGraph: invalid OpenGraph configuration")
	ErrOpenGraphTTLDoesNotParse = errors.New("config.OpenGraph: ttl does not parse as a Duration, see https://pkg.go.dev/time#ParseDuration (formatted like 5m -> 5 minutes, 2h -> 2 hours, etc)")
	ErrOpenGraphMissingProperty = errors.New("config.OpenGraph: default opengraph tags missing a property")
)

type openGraphFileConfig struct {
	Enabled      bool              `json:"enabled" yaml:"enabled"`
	ConsiderHost bool              `json:"considerHost" yaml:"enabled"`
	TimeToLive   string            `json:"ttl" yaml:"ttl"`
	Override     map[string]string `json:"override,omitempty" yaml:"override,omitempty"`
}

type OpenGraph struct {
	Enabled      bool              `json:"enabled" yaml:"enabled"`
	ConsiderHost bool              `json:"considerHost" yaml:"enabled"`
	Override     map[string]string `json:"override,omitempty" yaml:"override,omitempty"`
	TimeToLive   time.Duration     `json:"ttl" yaml:"ttl"`
}

func (og *openGraphFileConfig) Valid() error {
	var errs []error

	if _, err := time.ParseDuration(og.TimeToLive); err != nil {
		errs = append(errs, fmt.Errorf("%w: ParseDuration(%q) returned: %w", ErrOpenGraphTTLDoesNotParse, og.TimeToLive, err))
	}

	if len(og.Override) != 0 {
		for _, tag := range []string{
			"og:title",
		} {
			if _, ok := og.Override[tag]; !ok {
				errs = append(errs, fmt.Errorf("%w: %s", ErrOpenGraphMissingProperty, tag))
			}
		}
	}

	if len(errs) != 0 {
		return errors.Join(ErrInvalidOpenGraphConfig, errors.Join(errs...))
	}

	return nil
}

package config

import (
	"context"
	"errors"
	"fmt"
	"io"
)

var ErrMissingValue = errors.New("config: missing value")

type Impressum struct {
	Footer string        `json:"footer" yaml:"footer"`
	Page   ImpressumPage `json:"page" yaml:"page"`
}

func (i Impressum) Render(_ context.Context, w io.Writer) error {
	if _, err := fmt.Fprint(w, i.Footer); err != nil {
		return err
	}
	return nil
}

func (i Impressum) Valid() error {
	var errs []error

	if len(i.Footer) == 0 {
		errs = append(errs, fmt.Errorf("%w: impressum footer must be defined", ErrMissingValue))
	}

	if err := i.Page.Valid(); err != nil {
		errs = append(errs, err)
	}

	if len(errs) != 0 {
		return errors.Join(errs...)
	}

	return nil
}

type ImpressumPage struct {
	Title string `json:"title" yaml:"title"`
	Body  string `json:"body" yaml:"body"`
}

func (ip ImpressumPage) Render(_ context.Context, w io.Writer) error {
	if _, err := fmt.Fprint(w, ip.Body); err != nil {
		return err
	}

	return nil
}

func (ip ImpressumPage) Valid() error {
	var errs []error

	if len(ip.Title) == 0 {
		errs = append(errs, fmt.Errorf("%w: impressum page title must be defined", ErrMissingValue))
	}

	if len(ip.Body) == 0 {
		errs = append(errs, fmt.Errorf("%w: impressum body title must be defined", ErrMissingValue))
	}

	if len(errs) != 0 {
		return errors.Join(errs...)
	}

	return nil
}

package expressions

import (
	"context"
	"log/slog"
	"sync"
	"time"

	"github.com/shirou/gopsutil/v4/load"
)

type loadAvg struct {
	lock sync.RWMutex
	data *load.AvgStat
}

func (l *loadAvg) updateThread(ctx context.Context) {
	ticker := time.NewTicker(15 * time.Second)
	defer ticker.Stop()

	l.update()

	for {
		select {
		case <-ticker.C:
			l.update()
		case <-ctx.Done():
			return
		}
	}
}

func (l *loadAvg) update() {
	l.lock.Lock()
	defer l.lock.Unlock()

	var err error
	l.data, err = load.Avg()
	if err != nil {
		slog.Debug("can't get load average", "err", err)
	}
}

var (
	globalLoadAvg *loadAvg
)

func init() {
	globalLoadAvg = &loadAvg{}
	go globalLoadAvg.updateThread(context.Background())
}

func Load1() float64 {
	globalLoadAvg.lock.RLock()
	defer globalLoadAvg.lock.RUnlock()
	return globalLoadAvg.data.Load1
}

func Load5() float64 {
	globalLoadAvg.lock.RLock()
	defer globalLoadAvg.lock.RUnlock()
	return globalLoadAvg.data.Load5
}

func Load15() float64 {
	globalLoadAvg.lock.RLock()
	defer globalLoadAvg.lock.RUnlock()
	return globalLoadAvg.data.Load15
}

package challenge

import (
	"log/slog"
	"net/http"
	"sort"
	"sync"

	"github.com/TecharoHQ/anubis/lib/policy"
	"github.com/TecharoHQ/anubis/lib/policy/config"
	"github.com/TecharoHQ/anubis/lib/store"
	"github.com/a-h/templ"
)

var (
	registry map[string]Impl = map[string]Impl{}
	regLock  sync.RWMutex
)

func Register(name string, impl Impl) {
	regLock.Lock()
	defer regLock.Unlock()

	registry[name] = impl
}

func Get(name string) (Impl, bool) {
	regLock.RLock()
	defer regLock.RUnlock()
	result, ok := registry[name]
	return result, ok
}

func Methods() []string {
	regLock.RLock()
	defer regLock.RUnlock()
	var result []string
	for method := range registry {
		result = append(result, method)
	}
	sort.Strings(result)
	return result
}

type IssueInput struct {
	Impressum *config.Impressum
	Rule      *policy.Bot
	Challenge *Challenge
	OGTags    map[string]string
	Store     store.Interface
}

type ValidateInput struct {
	Rule      *policy.Bot
	Challenge *Challenge
	Store     store.Interface
}

type Impl interface {
	// Setup registers any additional routes with the Impl for assets or API routes.
	Setup(mux *http.ServeMux)

	// Issue a new challenge to the user, called by the Anubis.
	Issue(r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error)

	// Validate a challenge, making sure that it passes muster.
	Validate(r *http.Request, lg *slog.Logger, in *ValidateInput) error
}

package store

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"time"
)

var (
	// ErrNotFound is returned when the store implementation cannot find the value
	// for a given key.
	ErrNotFound = errors.New("store: key not found")

	// ErrCantDecode is returned when a store adaptor cannot decode the store format
	// to a value used by the code.
	ErrCantDecode = errors.New("store: can't decode value")

	// ErrCantEncode is returned when a store adaptor cannot encode the value into
	// the format that the store uses.
	ErrCantEncode = errors.New("store: can't encode value")

	// ErrBadConfig is returned when a store adaptor's configuration is invalid.
	ErrBadConfig = errors.New("store: configuration is invalid")
)

// Interface defines the calls that Anubis uses for storage in a local or remote
// datastore. This can be implemented with an in-memory, on-disk, or in-database
// storage backend.
type Interface interface {
	// Delete removes a value from the store by key.
	Delete(ctx context.Context, key string) error

	// Get returns the value of a key assuming that value exists and has not expired.
	Get(ctx context.Context, key string) ([]byte, error)

	// Set puts a value into the store that expires according to its expiry.
	Set(ctx context.Context, key string, value []byte, expiry time.Duration) error
}

func z[T any]() T { return *new(T) }

type JSON[T any] struct {
	Underlying Interface
	Prefix     string
}

func (j *JSON[T]) Delete(ctx context.Context, key string) error {
	if j.Prefix != "" {
		key = j.Prefix + key
	}

	return j.Underlying.Delete(ctx, key)
}

func (j *JSON[T]) Get(ctx context.Context, key string) (T, error) {
	if j.Prefix != "" {
		key = j.Prefix + key
	}

	data, err := j.Underlying.Get(ctx, key)
	if err != nil {
		return z[T](), err
	}

	var result T
	if err := json.Unmarshal(data, &result); err != nil {
		return z[T](), fmt.Errorf("%w: %w", ErrCantDecode, err)
	}

	return result, nil
}

func (j *JSON[T]) Set(ctx context.Context, key string, value T, expiry time.Duration) error {
	if j.Prefix != "" {
		key = j.Prefix + key
	}

	data, err := json.Marshal(value)
	if err != nil {
		return fmt.Errorf("%w: %w", ErrCantEncode, err)
	}

	if err := j.Underlying.Set(ctx, key, data, expiry); err != nil {
		return err
	}

	return nil
}

package internal

import (
	"net/http"

	"github.com/lum8rjack/go-ja4h"
)

func JA4H(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		r.Header.Add("X-Http-Fingerprint-JA4H", ja4h.JA4H(r))
		next.ServeHTTP(w, r)
	})
}

package policy

import (
	"fmt"

	"github.com/TecharoHQ/anubis/internal"
	"github.com/TecharoHQ/anubis/lib/policy/checker"
	"github.com/TecharoHQ/anubis/lib/policy/config"
)

type Bot struct {
	Rules     checker.Impl
	Challenge *config.ChallengeRules
	Weight    *config.Weight
	Name      string
	Action    config.Rule
}

func (b Bot) Hash() string {
	return internal.FastHash(fmt.Sprintf("%s::%s", b.Name, b.Rules.Hash()))
}

package main

import (
	"flag"
	"fmt"
	"log/slog"
	"os"
	"os/exec"
	"strings"
	"time"
)

var (
	startWait = flag.Duration("start-wait", 250*time.Millisecond, "amount of time to start with exponential backoff")
	tryCount  = flag.Int("try-count", 5, "number of retries")
)

func main() {
	flag.Parse()

	cmdStr := strings.Join(flag.Args(), " ")
	wait := *startWait

	for i := range make([]struct{}, *tryCount) {
		slog.Info("executing", "try", i+1, "wait", wait, "cmd", cmdStr)

		cmd := exec.Command("sh", "-c", cmdStr)
		cmd.Stdin = nil
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr

		if err := cmd.Run(); err != nil {
			time.Sleep(wait)
			wait = wait * 2
		} else {
			os.Exit(0)
		}
	}

	fmt.Printf("giving up after %d tries\n", *tryCount)
	os.Exit(1)
}

package policy

import (
	"github.com/TecharoHQ/anubis/lib/policy/config"
	"github.com/TecharoHQ/anubis/lib/policy/expressions"
	"github.com/google/cel-go/cel"
)

type Threshold struct {
	config.Threshold
	Program cel.Program
}

func ParsedThresholdFromConfig(t config.Threshold) (*Threshold, error) {
	result := &Threshold{
		Threshold: t,
	}

	env, err := expressions.ThresholdEnvironment()
	if err != nil {
		return nil, err
	}

	program, err := expressions.Compile(env, t.Expression.String())
	if err != nil {
		return nil, err
	}

	result.Program = program

	return result, nil
}

type ThresholdRequest struct {
	Weight int
}

func (tr *ThresholdRequest) Parent() cel.Activation { return nil }

func (tr *ThresholdRequest) ResolveName(name string) (any, bool) {
	switch name {
	case "weight":
		return tr.Weight, true
	default:
		return nil, false
	}
}

package policy

import (
	"log/slog"

	"github.com/TecharoHQ/anubis/lib/policy/config"
)

type CheckResult struct {
	Name   string
	Rule   config.Rule
	Weight int
}

func (cr CheckResult) LogValue() slog.Value {
	return slog.GroupValue(
		slog.String("name", cr.Name),
		slog.String("rule", string(cr.Rule)),
		slog.Int("weight", cr.Weight),
	)
}

package config

import (
	"errors"
	"fmt"
)

var (
	ErrPrivateASN = errors.New("bot.ASNs: you have specified a private use ASN")
)

type ASNs struct {
	Match []uint32 `json:"match"`
}

func (a *ASNs) Valid() error {
	var errs []error

	for _, asn := range a.Match {
		if isPrivateASN(asn) {
			errs = append(errs, fmt.Errorf("%w: %d is private (see RFC 6996)", ErrPrivateASN, asn))
		}
	}

	if len(errs) != 0 {
		return fmt.Errorf("bot.ASNs: invalid ASN settings: %w", errors.Join(errs...))
	}

	return nil
}

// isPrivateASN checks if an ASN is in the private use area.
//
// Based on RFC 6996 and IANA allocations.
func isPrivateASN(asn uint32) bool {
	switch {
	case asn >= 64512 && asn <= 65534:
		return true
	case asn >= 4200000000 && asn <= 4294967294:
		return true
	default:
		return false
	}
}

package config

import (
	"errors"
	"fmt"
	"regexp"
	"strings"
)

var (
	countryCodeRegexp = regexp.MustCompile(`^[a-zA-Z]{2}$`)

	ErrNotCountryCode = errors.New("config.Bot: invalid country code")
)

type GeoIP struct {
	Countries []string `json:"countries"`
}

func (g *GeoIP) Valid() error {
	var errs []error

	for i, cc := range g.Countries {
		if !countryCodeRegexp.MatchString(cc) {
			errs = append(errs, fmt.Errorf("%w: %s", ErrNotCountryCode, cc))
		}

		g.Countries[i] = strings.ToLower(cc)
	}

	if len(errs) != 0 {
		return fmt.Errorf("bot.GeoIP: invalid GeoIP settings: %w", errors.Join(errs...))
	}

	return nil
}

// Package xess vendors a copy of Xess and makes it available at /.xess/xess.css
//
// This is intended to be used as a vendored package in other projects.
package xess

import (
	"embed"
	"net/http"
	"path/filepath"

	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/internal"
)

var (
	//go:embed *.css static
	Static embed.FS

	URL = "/.within.website/x/xess/xess.css"
)

func init() {
	Mount(http.DefaultServeMux)

	//goland:noinspection GoBoolExpressions
	if anubis.Version != "devel" {
		URL = filepath.Join(filepath.Dir(URL), "xess.min.css")
	}

	URL = URL + "?cachebuster=" + anubis.Version
}

// Mount registers the xess static file handlers on the given mux
func Mount(mux *http.ServeMux) {
	prefix := anubis.BasePrefix + "/.within.website/x/xess/"

	mux.Handle(prefix, internal.UnchangingCache(http.StripPrefix(prefix, http.FileServerFS(Static))))
}

package challenge

import (
	"math"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
)

var TimeTaken = promauto.NewHistogramVec(prometheus.HistogramOpts{
	Name:    "anubis_time_taken",
	Help:    "The time taken for a browser to generate a response (milliseconds)",
	Buckets: prometheus.ExponentialBucketsRange(1, math.Pow(2, 20), 20),
}, []string{"method"})

// Package anubis contains the version number of Anubis.
package anubis

import "time"

// Version is the current version of Anubis.
//
// This variable is set at build time using the -X linker flag. If not set,
// it defaults to "devel".
var Version = "devel"

// CookieName is the name of the cookie that Anubis uses in order to validate
// access.
var CookieName = "techaro.lol-anubis-auth"

// TestCookieName is the name of the cookie that Anubis uses in order to check
// if cookies are enabled on the client's browser.
var TestCookieName = "techaro.lol-anubis-cookie-verification"

// CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires.
const CookieDefaultExpirationTime = 7 * 24 * time.Hour

// BasePrefix is a global prefix for all Anubis endpoints. Can be emptied to remove the prefix entirely.
var BasePrefix = ""

// StaticPath is the location where all static Anubis assets are located.
const StaticPath = "/.within.website/x/cmd/anubis/"

// APIPrefix is the location where all Anubis API endpoints are located.
const APIPrefix = "/.within.website/x/cmd/anubis/api/"

// DefaultDifficulty is the default "difficulty" (number of leading zeroes)
// that must be met by the client in order to pass the challenge.
const DefaultDifficulty = 4

// ForcedLanguage is the language being used instead of the one of the request's Accept-Language header
// if being set.
var ForcedLanguage = ""

// Package checker defines the Checker interface and a helper utility to avoid import cycles.
package checker

import (
	"fmt"
	"net/http"
	"strings"

	"github.com/TecharoHQ/anubis/internal"
)

type Impl interface {
	Check(*http.Request) (bool, error)
	Hash() string
}

type List []Impl

func (l List) Check(r *http.Request) (bool, error) {
	for _, c := range l {
		ok, err := c.Check(r)
		if err != nil {
			return ok, err
		}
		if ok {
			return ok, nil
		}
	}

	return false, nil
}

func (l List) Hash() string {
	var sb strings.Builder

	for _, c := range l {
		fmt.Fprintln(&sb, c.Hash())
	}

	return internal.FastHash(sb.String())
}

// Code generated by "stringer -type=DroneBLResponse"; DO NOT EDIT.

package dnsbl

import "strconv"

func _() {
	// An "invalid array index" compiler error signifies that the constant values have changed.
	// Re-run the stringer command to generate them again.
	var x [1]struct{}
	_ = x[AllGood-0]
	_ = x[IRCDrone-3]
	_ = x[Bottler-5]
	_ = x[UnknownSpambotOrDrone-6]
	_ = x[DDOSDrone-7]
	_ = x[SOCKSProxy-8]
	_ = x[HTTPProxy-9]
	_ = x[ProxyChain-10]
	_ = x[OpenProxy-11]
	_ = x[OpenDNSResolver-12]
	_ = x[BruteForceAttackers-13]
	_ = x[OpenWingateProxy-14]
	_ = x[CompromisedRouter-15]
	_ = x[AutoRootingWorms-16]
	_ = x[AutoDetectedBotIP-17]
	_ = x[Unknown-255]
}

const (
	_DroneBLResponse_name_0 = "AllGood"
	_DroneBLResponse_name_1 = "IRCDrone"
	_DroneBLResponse_name_2 = "BottlerUnknownSpambotOrDroneDDOSDroneSOCKSProxyHTTPProxyProxyChainOpenProxyOpenDNSResolverBruteForceAttackersOpenWingateProxyCompromisedRouterAutoRootingWormsAutoDetectedBotIP"
	_DroneBLResponse_name_3 = "Unknown"
)

var (
	_DroneBLResponse_index_2 = [...]uint8{0, 7, 28, 37, 47, 56, 66, 75, 90, 109, 125, 142, 158, 175}
)

func (i DroneBLResponse) String() string {
	switch {
	case i == 0:
		return _DroneBLResponse_name_0
	case i == 3:
		return _DroneBLResponse_name_1
	case 5 <= i && i <= 17:
		i -= 5
		return _DroneBLResponse_name_2[_DroneBLResponse_index_2[i]:_DroneBLResponse_index_2[i+1]]
	case i == 255:
		return _DroneBLResponse_name_3
	default:
		return "DroneBLResponse(" + strconv.FormatInt(int64(i), 10) + ")"
	}
}

package internal

import (
	"context"

	"google.golang.org/grpc/health"
	healthv1 "google.golang.org/grpc/health/grpc_health_v1"
)

var HealthSrv = health.NewServer()

func SetHealth(svc string, status healthv1.HealthCheckResponse_ServingStatus) {
	HealthSrv.SetServingStatus(svc, status)
}

func GetHealth(svc string) (healthv1.HealthCheckResponse_ServingStatus, bool) {
	st, err := HealthSrv.Check(context.Background(), &healthv1.HealthCheckRequest{
		Service: svc,
	})
	if err != nil {
		return healthv1.HealthCheckResponse_UNKNOWN, false
	}

	return st.GetStatus(), true
}

package expressions

import (
	"errors"
	"net/url"
	"reflect"
	"strings"

	"github.com/google/cel-go/common/types"
	"github.com/google/cel-go/common/types/ref"
	"github.com/google/cel-go/common/types/traits"
)

var ErrNotImplemented = errors.New("expressions: not implemented")

// URLValues is a type wrapper to expose url.Values into CEL programs.
type URLValues struct {
	url.Values
}

func (u URLValues) ConvertToNative(typeDesc reflect.Type) (any, error) {
	return nil, ErrNotImplemented
}

func (u URLValues) ConvertToType(typeVal ref.Type) ref.Val {
	switch typeVal {
	case types.MapType:
		return u
	case types.TypeType:
		return types.MapType
	}

	return types.NewErr("can't convert from %q to %q", types.MapType, typeVal)
}

func (u URLValues) Equal(other ref.Val) ref.Val {
	return types.Bool(false) // We don't want to compare header maps
}

func (u URLValues) Type() ref.Type {
	return types.MapType
}

func (u URLValues) Value() any { return u }

func (u URLValues) Find(key ref.Val) (ref.Val, bool) {
	k, ok := key.(types.String)
	if !ok {
		return nil, false
	}

	if _, ok := u.Values[string(k)]; !ok {
		return nil, false
	}

	return types.String(strings.Join(u.Values[string(k)], ",")), true
}

func (u URLValues) Contains(key ref.Val) ref.Val {
	_, ok := u.Find(key)
	return types.Bool(ok)
}

func (u URLValues) Get(key ref.Val) ref.Val {
	result, ok := u.Find(key)
	if !ok {
		return types.ValOrErr(result, "no such key: %v", key)
	}
	return result
}

func (u URLValues) Iterator() traits.Iterator { panic("TODO(Xe): implement me") }

func (u URLValues) IsZeroValue() bool {
	return len(u.Values) == 0
}

func (u URLValues) Size() ref.Val { return types.Int(len(u.Values)) }

package challenge

import (
	"errors"
	"fmt"
	"net/http"
)

var (
	ErrFailed        = errors.New("challenge: user failed challenge")
	ErrMissingField  = errors.New("challenge: missing field")
	ErrInvalidFormat = errors.New("challenge: field has invalid format")
)

func NewError(verb, publicReason string, privateReason error) *Error {
	return &Error{
		Verb:          verb,
		PublicReason:  publicReason,
		PrivateReason: privateReason,
		StatusCode:    http.StatusForbidden,
	}
}

type Error struct {
	PrivateReason error
	Verb          string
	PublicReason  string
	StatusCode    int
}

func (e *Error) Error() string {
	return fmt.Sprintf("challenge: error when processing challenge: %s: %v", e.Verb, e.PrivateReason)
}

func (e *Error) Unwrap() error {
	return e.PrivateReason
}

package decaymap

import (
	"sync"
	"time"
)

func Zilch[T any]() T {
	var zero T
	return zero
}

// Impl is a lazy key->value map. It's a wrapper around a map and a mutex. If values exceed their time-to-live, they are pruned at Get time.
type Impl[K comparable, V any] struct {
	data map[K]decayMapEntry[V]
	lock sync.RWMutex
}

type decayMapEntry[V any] struct {
	Value  V
	expiry time.Time
}

// New creates a new DecayMap of key type K and value type V.
//
// Key types must be comparable to work with maps.
func New[K comparable, V any]() *Impl[K, V] {
	return &Impl[K, V]{
		data: make(map[K]decayMapEntry[V]),
	}
}

// expire forcibly expires a key by setting its time-to-live one second in the past.
func (m *Impl[K, V]) expire(key K) bool {
	m.lock.RLock()
	val, ok := m.data[key]
	m.lock.RUnlock()

	if !ok {
		return false
	}

	m.lock.Lock()
	val.expiry = time.Now().Add(-1 * time.Second)
	m.data[key] = val
	m.lock.Unlock()

	return true
}

// Delete a value from the DecayMap by key.
//
// If the value does not exist, return false. Return true after
// deletion.
func (m *Impl[K, V]) Delete(key K) bool {
	m.lock.RLock()
	_, ok := m.data[key]
	m.lock.RUnlock()

	if !ok {
		return false
	}

	m.lock.Lock()
	delete(m.data, key)
	m.lock.Unlock()

	return true
}

// Get gets a value from the DecayMap by key.
//
// If a value has expired, forcibly delete it if it was not updated.
func (m *Impl[K, V]) Get(key K) (V, bool) {
	m.lock.RLock()
	value, ok := m.data[key]
	m.lock.RUnlock()

	if !ok {
		return Zilch[V](), false
	}

	if time.Now().After(value.expiry) {
		m.lock.Lock()
		// Since previously reading m.data[key], the value may have been updated.
		// Delete the entry only if the expiry time is still the same.
		if m.data[key].expiry.Equal(value.expiry) {
			delete(m.data, key)
		}
		m.lock.Unlock()

		return Zilch[V](), false
	}

	return value.Value, true
}

// Set sets a key value pair in the map.
func (m *Impl[K, V]) Set(key K, value V, ttl time.Duration) {
	m.lock.Lock()
	defer m.lock.Unlock()

	m.data[key] = decayMapEntry[V]{
		Value:  value,
		expiry: time.Now().Add(ttl),
	}
}

// Cleanup removes all expired entries from the DecayMap.
func (m *Impl[K, V]) Cleanup() {
	m.lock.Lock()
	defer m.lock.Unlock()

	now := time.Now()
	for key, entry := range m.data {
		if now.After(entry.expiry) {
			delete(m.data, key)
		}
	}
}

// Len returns the number of entries in the DecayMap.
func (m *Impl[K, V]) Len() int {
	m.lock.RLock()
	defer m.lock.RUnlock()
	return len(m.data)
}

package expressions

import (
	"net/http"
	"reflect"
	"strings"

	"github.com/google/cel-go/common/types"
	"github.com/google/cel-go/common/types/ref"
	"github.com/google/cel-go/common/types/traits"
)

// HTTPHeaders is a type wrapper to expose HTTP headers into CEL programs.
type HTTPHeaders struct {
	http.Header
}

func (h HTTPHeaders) ConvertToNative(typeDesc reflect.Type) (any, error) {
	return nil, ErrNotImplemented
}

func (h HTTPHeaders) ConvertToType(typeVal ref.Type) ref.Val {
	switch typeVal {
	case types.MapType:
		return h
	case types.TypeType:
		return types.MapType
	}

	return types.NewErr("can't convert from %q to %q", types.MapType, typeVal)
}

func (h HTTPHeaders) Equal(other ref.Val) ref.Val {
	return types.Bool(false) // We don't want to compare header maps
}

func (h HTTPHeaders) Type() ref.Type {
	return types.MapType
}

func (h HTTPHeaders) Value() any { return h }

func (h HTTPHeaders) Find(key ref.Val) (ref.Val, bool) {
	k, ok := key.(types.String)
	if !ok {
		return nil, false
	}

	if _, ok := h.Header[string(k)]; !ok {
		return nil, false
	}

	return types.String(strings.Join(h.Header.Values(string(k)), ",")), true
}

func (h HTTPHeaders) Contains(key ref.Val) ref.Val {
	_, ok := h.Find(key)
	return types.Bool(ok)
}

func (h HTTPHeaders) Get(key ref.Val) ref.Val {
	result, ok := h.Find(key)
	if !ok {
		return types.ValOrErr(result, "no such key: %v", key)
	}
	return result
}

func (h HTTPHeaders) Iterator() traits.Iterator { panic("TODO(Xe): implement me") }

func (h HTTPHeaders) IsZeroValue() bool {
	return len(h.Header) == 0
}

func (h HTTPHeaders) Size() ref.Val { return types.Int(len(h.Header)) }

package ogtags

import (
	"strings"

	"golang.org/x/net/html"
)

// extractOGTags traverses the HTML document and extracts approved Open Graph tags
func (c *OGTagCache) extractOGTags(doc *html.Node) map[string]string {
	ogTags := make(map[string]string)

	var traverseNodes func(*html.Node)
	traverseNodes = func(n *html.Node) {
		if isOGMetaTag(n) {
			property, content := c.extractMetaTagInfo(n)
			if property != "" {
				ogTags[property] = content
			}
		}
		for child := n.FirstChild; child != nil; child = child.NextSibling {
			traverseNodes(child)
		}
	}

	traverseNodes(doc)
	return ogTags
}

// isOGMetaTag checks if a node is *any* meta tag
func isOGMetaTag(n *html.Node) bool {
	if n == nil {
		return false
	}
	return n.Type == html.ElementNode && n.Data == "meta"
}

// extractMetaTagInfo extracts property and content from a meta tag
func (c *OGTagCache) extractMetaTagInfo(n *html.Node) (property, content string) {
	var propertyKey string

	// Single pass through attributes, using range to avoid bounds checking
	for _, attr := range n.Attr {
		switch attr.Key {
		case "property", "name":
			propertyKey = attr.Val
		case "content":
			content = attr.Val
		}
		// Early exit if we have both
		if propertyKey != "" && content != "" {
			break
		}
	}

	if propertyKey == "" {
		return "", content
	}

	// Check prefixes first (more common case)
	for _, prefix := range c.approvedPrefixes {
		if strings.HasPrefix(propertyKey, prefix) {
			return propertyKey, content
		}
	}

	// Check exact matches
	for _, tag := range c.approvedTags {
		if propertyKey == tag {
			return propertyKey, content
		}
	}

	return "", content
}

package internal

import (
	"crypto/sha256"
	"encoding/hex"
	"strconv"

	"github.com/cespare/xxhash/v2"
)

// SHA256sum computes a cryptographic hash. Still used for proof-of-work challenges
// where we need the security properties of a cryptographic hash function.
func SHA256sum(text string) string {
	hash := sha256.New()
	hash.Write([]byte(text))
	return hex.EncodeToString(hash.Sum(nil))
}

// FastHash is a high-performance non-cryptographic hash function suitable for
// internal caching, policy rule identification, and other performance-critical
// use cases where cryptographic security is not required.
func FastHash(text string) string {
	h := xxhash.Sum64String(text)
	return strconv.FormatUint(h, 16)
}

package internal

import (
	"os"
	"os/exec"
)

func UnbreakDocker() {
	// XXX(Xe): This is bad code. Do not do this.
	//
	// I have to do this because I'm running from inside the context of a dev
	// container. This dev container runs in a different docker network than
	// the valkey test container runs in. In order to let my dev container
	// connect to the test container, they need to share a network in common.
	// The easiest network to use for this is the default "bridge" network.
	//
	// This is a horrifying monstrosity, but the part that scares me the most
	// is the fact that it works.
	if hostname, err := os.Hostname(); err == nil {
		exec.Command("docker", "network", "connect", "bridge", hostname).Run()
	}
}

package thoth

import (
	"context"

	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
)

func authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor {
	return func(
		ctx context.Context,
		method string,
		req interface{},
		reply interface{},
		cc *grpc.ClientConn,
		invoker grpc.UnaryInvoker,
		opts ...grpc.CallOption,
	) error {
		md := metadata.Pairs("authorization", "Bearer "+token)
		ctx = metadata.NewOutgoingContext(ctx, md)
		return invoker(ctx, method, req, reply, cc, opts...)
	}
}

func authStreamClientInterceptor(token string) grpc.StreamClientInterceptor {
	return func(
		ctx context.Context,
		desc *grpc.StreamDesc,
		cc *grpc.ClientConn,
		method string,
		streamer grpc.Streamer,
		opts ...grpc.CallOption,
	) (grpc.ClientStream, error) {
		md := metadata.Pairs("authorization", "Bearer "+token)
		ctx = metadata.NewOutgoingContext(ctx, md)
		return streamer(ctx, desc, cc, method, opts...)
	}
}

package thothmock

import (
	"context"
	"testing"

	"github.com/TecharoHQ/anubis/lib/thoth"
)

func WithMockThoth(t *testing.T) context.Context {
	t.Helper()

	thothCli := &thoth.Client{}
	thothCli.WithIPToASNService(MockIpToASNService())
	ctx := thoth.With(t.Context(), thothCli)
	return ctx
}

package store

import (
	"context"
	"encoding/json"
	"sort"
	"sync"
)

var (
	registry map[string]Factory = map[string]Factory{}
	regLock  sync.RWMutex
)

type Factory interface {
	Build(ctx context.Context, config json.RawMessage) (Interface, error)
	Valid(config json.RawMessage) error
}

func Register(name string, impl Factory) {
	regLock.Lock()
	defer regLock.Unlock()

	registry[name] = impl
}

func Get(name string) (Factory, bool) {
	regLock.RLock()
	defer regLock.RUnlock()
	result, ok := registry[name]
	return result, ok
}

func Methods() []string {
	regLock.RLock()
	defer regLock.RUnlock()
	var result []string
	for method := range registry {
		result = append(result, method)
	}
	sort.Strings(result)
	return result
}

// Package all is a meta-package that imports all store implementations.
//
// This is a HACK to make tests work consistently.
package all

import (
	_ "github.com/TecharoHQ/anubis/lib/store/bbolt"
	_ "github.com/TecharoHQ/anubis/lib/store/memory"
	_ "github.com/TecharoHQ/anubis/lib/store/valkey"
)

package thoth

import "context"

type ctxKey struct{}

func With(ctx context.Context, cli *Client) context.Context {
	return context.WithValue(ctx, ctxKey{}, cli)
}

func FromContext(ctx context.Context) (*Client, bool) {
	cli, ok := ctx.Value(ctxKey{}).(*Client)
	return cli, ok
}

package challenge

import "time"

// Challenge is the metadata about a single challenge issuance.
type Challenge struct {
	ID         string            `json:"id"`         // UUID identifying the challenge
	RandomData string            `json:"randomData"` // The random data the client processes
	IssuedAt   time.Time         `json:"issuedAt"`   // When the challenge was issued
	Metadata   map[string]string `json:"metadata"`   // Challenge metadata such as IP address and user agent
}

package web

import "embed"

//go:generate go tool github.com/a-h/templ/cmd/templ generate

var (
	//go:embed static
	Static embed.FS
)

package data

import "embed"

var (
	//go:embed botPolicies.yaml botPolicies.json all:apps all:bots all:clients all:common all:crawlers all:meta
	BotPolicies embed.FS
)

package config

type Weight struct {
	Adjust int `json:"adjust" yaml:"adjust"`
}

package internal

import "mime"

func init() {
	mime.AddExtensionType(".mjs", "text/javascript")
}

func (s *Server) MakeChallenge (w http.ResponseWriter, r *http.Request) {}

This code segment functions to: Process incoming requests to generate security challenges by validating required parameters, applying policy-based access checks, issuing cryptographic challenge tokens, and returning structured challenge data with associated validation rules in a JSON response format.

```go
[TASK_BEGIN]
func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
	lg := internal.GetRequestLogger(r)
	localizer := localization.GetLocalizer(r)

	// Issue new challenge
	chall, err := s.issueChallenge(r.Context(), r)
	if err != nil {
		lg.Error("failed to create challenge", "err", err)
		s.respondWithError(w, r, localizer.T("internal_server_error"))
		return
	}

	// Set test cookie
	cookiePath := "/"
	if anubis.BasePrefix != "" {
		cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
	}

	s.SetCookie(w, CookieOpts{
		Name:     anubis.TestCookieName,
		Value:    chall.ID,
		Path:     cookiePath,
		Host:     r.Host,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
		Expires:  time.Now().Add(30 * time.Minute),
	})

	// Return challenge details
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(chall); err != nil {
		lg.Error("failed to encode challenge response", "err", err)
		http.Error(w, localizer.T("internal_server_error"), http.StatusInternalServerError)
	}
}
[TASK_END]
```

func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
	lg := internal.GetRequestLogger(r)
	localizer := localization.GetLocalizer(r)
	chall, err := s.issueChallenge(r.Context(), r)
	if err != nil {
		lg.Error("failed to create challenge", "err", err)
		s.respondWithError(w, r, localizer.T("internal_server_error"))
		return
	}
	cookiePath := "/"
	if anubis.BasePrefix != "" {
		cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
	}
	s.SetCookie(w, CookieOpts{
		Name:     anubis.TestCookieName,
		Value:    chall.ID,
		Path:     cookiePath,
		Host:     r.Host,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
		Expires:  time.Now().Add(30 * time.Minute),
	})
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(chall); err != nil {
		lg.Error("failed to encode challenge response", "err", err)
		http.Error(w, localizer.T("internal_server_error"), http.StatusInternalServerError)
	}
}