feat: implement imprint/impressum support (#706)

* feat: implement imprint/impressum support

Closes #362

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

* chore(docs/anubis): enable an imprint

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

* chore: spelling

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

* docs: fix the end of the sentence, comment out a default impressum

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

* docs: link back to impressum page

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

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Xe Iaso 2025-06-22 18:09:37 -04:00 committed by GitHub
parent 3c1d95d61e
commit 5870f7072c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 530 additions and 130 deletions

View file

@ -7,6 +7,7 @@ import (
"sync"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/a-h/templ"
)
@ -40,12 +41,19 @@ func Methods() []string {
return result
}
type IssueInput struct {
Impressum *config.Impressum
Rule *policy.Bot
Challenge string
OGTags map[string]string
}
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, rule *policy.Bot, challenge string, ogTags map[string]string) (templ.Component, error)
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, rule *policy.Bot, challenge string) error

View file

@ -23,7 +23,7 @@ type Impl struct{}
func (i *Impl) Setup(mux *http.ServeMux) {}
func (i *Impl) Issue(r *http.Request, lg *slog.Logger, rule *policy.Bot, challenge string, ogTags map[string]string) (templ.Component, error) {
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)
@ -31,10 +31,10 @@ func (i *Impl) Issue(r *http.Request, lg *slog.Logger, rule *policy.Bot, challen
q := u.Query()
q.Set("redir", r.URL.String())
q.Set("challenge", challenge)
q.Set("challenge", in.Challenge)
u.RawQuery = q.Encode()
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", page(challenge, u.String(), rule.Challenge.Difficulty), challenge, rule.Challenge, ogTags)
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", page(in.Challenge, u.String(), in.Rule.Challenge.Difficulty), in.Impressum, in.Challenge, in.Rule.Challenge, in.OGTags)
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
}

View file

@ -28,8 +28,8 @@ func (i *Impl) Setup(mux *http.ServeMux) {
/* no implementation required */
}
func (i *Impl) Issue(r *http.Request, lg *slog.Logger, rule *policy.Bot, challenge string, ogTags map[string]string) (templ.Component, error) {
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags)
func (i *Impl) Issue(r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) {
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), in.Impressum, in.Challenge, in.Rule.Challenge, in.OGTags)
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
}

View file

@ -124,7 +124,12 @@ func TestBasic(t *testing.T) {
t.Run(cs.name, func(t *testing.T) {
lg := slog.With()
if _, err := i.Issue(cs.req, lg, bot, cs.challengeStr, nil); err != nil {
inp := &challenge.IssueInput{
Rule: bot,
Challenge: cs.challengeStr,
}
if _, err := i.Issue(cs.req, lg, inp); err != nil {
t.Errorf("can't issue challenge: %v", err)
}

View file

@ -24,6 +24,7 @@ import (
"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 {
@ -149,6 +150,14 @@ func New(opts Options) (*Server, error) {
}), "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),
).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), "")

View file

@ -102,7 +102,14 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
return
}
component, err := impl.Issue(r, lg, rule, challengeStr, ogTags)
in := &challenge.IssueInput{
Impressum: s.policy.Impressum,
Rule: rule,
Challenge: challengeStr,
OGTags: ogTags,
}
component, err := impl.Issue(r, lg, in)
if err != nil {
lg.Error("[unexpected] render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this.
s.respondWithError(w, r, "Internal Server Error: please contact the administrator and ask them to look for the logs around \"RenderIndex\"")
@ -118,7 +125,7 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
templ.Handler(
web.Base("Benchmarking Anubis!", web.Bench()),
web.Base("Benchmarking Anubis!", web.Bench(), s.policy.Impressum),
).ServeHTTP(w, r)
}
@ -127,7 +134,7 @@ func (s *Server) respondWithError(w http.ResponseWriter, r *http.Request, messag
}
func (s *Server) respondWithStatus(w http.ResponseWriter, r *http.Request, msg string, status int) {
templ.Handler(web.Base("Oh noes!", web.ErrorPage(msg, s.opts.WebmasterEmail)), templ.WithStatus(status)).ServeHTTP(w, r)
templ.Handler(web.Base("Oh noes!", web.ErrorPage(msg, s.opts.WebmasterEmail), s.policy.Impressum), templ.WithStatus(status)).ServeHTTP(w, r)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@ -180,7 +187,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
}
templ.Handler(
web.Base("You are not a bot!", web.StaticHappy()),
web.Base("You are not a bot!", web.StaticHappy(), s.policy.Impressum),
).ServeHTTP(w, r)
} else {
requestsProxied.WithLabelValues(r.Host).Inc()

View file

@ -327,6 +327,7 @@ 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"`
Thresholds []Threshold `json:"thresholds"`
}
@ -421,6 +422,14 @@ func Load(fin io.Reader, fname string) (*Config, error) {
}
}
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
}
@ -445,6 +454,7 @@ type Config struct {
Bots []BotConfig
Thresholds []Threshold
DNSBL bool
Impressum *Impressum
OpenGraph OpenGraph
StatusCodes StatusCodes
}

View file

@ -0,0 +1,71 @@
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
}

View file

@ -0,0 +1,62 @@
package config
import (
"bytes"
"errors"
"testing"
)
func TestImpressumValid(t *testing.T) {
for _, cs := range []struct {
name string
inp Impressum
err error
}{
{
name: "basic happy path",
inp: Impressum{
Footer: "<p>Website hosted by Techaro.<p>",
Page: ImpressumPage{
Title: "Techaro Imprint",
Body: "<p>This is an imprint page.</p>",
},
},
err: nil,
},
{
name: "no footer",
inp: Impressum{
Footer: "",
Page: ImpressumPage{
Title: "Techaro Imprint",
Body: "<p>This is an imprint page.</p>",
},
},
err: ErrMissingValue,
},
{
name: "page not valid",
inp: Impressum{
Footer: "test page please ignore",
},
err: ErrMissingValue,
},
} {
t.Run(cs.name, func(t *testing.T) {
if err := cs.inp.Valid(); !errors.Is(err, cs.err) {
t.Logf("want: %v", cs.err)
t.Logf("got: %v", err)
t.Error("validation failed")
}
var buf bytes.Buffer
if err := cs.inp.Render(t.Context(), &buf); err != nil {
t.Errorf("can't render footer: %v", err)
}
if err := cs.inp.Page.Render(t.Context(), &buf); err != nil {
t.Errorf("can't render page: %v", err)
}
})
}
}

View file

@ -0,0 +1,11 @@
bots:
- name: simple-weight-adjust
action: WEIGH
user_agent_regex: Mozilla
weight:
adjust: 5
impressum:
page:
title: Test
body: <p>This is a test</p>

View file

@ -0,0 +1,10 @@
bots:
- name: simple-weight-adjust
action: WEIGH
user_agent_regex: Mozilla
weight:
adjust: 5
impressum:
footer: "Hi there these are WORDS on the INTERNET."
page: {}

View file

@ -0,0 +1,10 @@
bots:
- name: simple
action: CHALLENGE
user_agent_regex: Mozilla
impressum:
footer: "Hi these are WORDS on the INTERNET."
page:
title: Test
body: <p>This is a test</p>

View file

@ -31,6 +31,7 @@ type ParsedConfig struct {
Bots []Bot
Thresholds []*Threshold
DNSBL bool
Impressum *config.Impressum
OpenGraph config.OpenGraph
DefaultDifficulty int
StatusCodes config.StatusCodes
@ -150,6 +151,8 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
parsedBot.Weight = b.Weight
}
result.Impressum = c.Impressum
parsedBot.Rules = cl
result.Bots = append(result.Bots, parsedBot)