[wip] Go coooode

This commit is contained in:
Yannik Rödel 2026-01-05 13:14:27 +01:00
parent 48137bf72b
commit 2ae058de8c
14 changed files with 1057 additions and 0 deletions

66
server/angestoepselt.go Normal file
View file

@ -0,0 +1,66 @@
package main
import (
"fmt"
"net/http"
)
import "github.com/gorilla/schema"
var decoder = schema.NewDecoder()
func setupSite(mux *http.ServeMux) {
form{
path: "/kontakt",
toSubmission: func(r *http.Request) (submission, error) {
var data struct {
Name string `schema:"contactname"`
Email string `schema:"contactemail"`
Message string `schema:"message"`
}
err := decoder.Decode(&data, r.PostForm)
if err != nil {
return submission{}, fmt.Errorf("POST decode error: %w", err)
}
return submission{
group: "",
category: "",
message: "",
information: nil,
}, nil
},
}.setup(mux)
form{
path: "/computer-beantragen/privat",
toSubmission: func(r *http.Request) (submission, error) {
var data struct {
Name string `schema:"contactname"`
Email string `schema:"contactemail"`
Message string `schema:"message"`
Addressline string `schema:"addressline"`
Postalcode string `schema:"postalcode"`
City string `schema:"city"`
}
err := decoder.Decode(&data, r.PostForm)
if err != nil {
return submission{}, fmt.Errorf("POST decode error: %w", err)
}
//documentFile, documentHeader, err := r.FormFile("document")
//if err != nil {
// return submission{}, fmt.Errorf("could not extract document upload: %w", err)
//}
//documentType := mime.TypeByExtension(documentHeader.Filename)
return submission{
group: "",
category: "",
message: "",
information: nil,
}, nil
},
}.setup(mux)
}

View file

@ -0,0 +1,56 @@
// Package antispam defines HTML templates and the corresponding server-side
// logic for integrating captcha providers.
package antispam
import (
"context"
"html/template"
"log/slog"
"net/http"
"os"
)
type Antispam interface {
// Script contains HTML `<script>` tag(s) that need to be injected into the
// form content or document head.
Script() template.HTML
// FormField is the template that needs to be injected into the `<form>`.
FormField() template.HTML
// CheckRequest uses the captcha provider to determine if request should be
// allowed to proceed.
//
// Implementations using HTTP forms may require r.ParseForm() to have been
// called.
CheckRequest(r *http.Request) (bool, error)
}
type antispamKey struct{}
// GetAntispam returns the active antispam implementation from request context.
func GetAntispam(r *http.Request) Antispam {
if antispam, ok := r.Context().Value(antispamKey{}).(Antispam); ok {
return antispam
}
return NewNoop()
}
// WithAntispam chooses a suitable antispam implementation sets up request
// context accordingly.
func WithAntispam(next http.Handler) http.HandlerFunc {
hCaptchaSiteKey := os.Getenv("HCAPTCHA_SITE_KEY")
hCaptchaSecretKey := os.Getenv("HCAPTCHA_SECRET_KEY")
if hCaptchaSiteKey != "" && hCaptchaSecretKey != "" {
slog.Info("antispam: using hCaptcha implementation")
return withAntispam(next, NewHcaptcha(hCaptchaSiteKey, hCaptchaSecretKey))
}
slog.Info("antispam: falling back to noop implementation")
return next.ServeHTTP
}
func withAntispam(next http.Handler, impl Antispam) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), antispamKey{}, impl)
next.ServeHTTP(w, r.WithContext(ctx))
}
}

View file

@ -0,0 +1,83 @@
package antispam
import (
"encoding/json"
"fmt"
"html/template"
"log"
"log/slog"
"net/http"
"net/url"
"strings"
)
type HCaptcha struct {
formField template.HTML
secretKey string
siteverifyEndpoint string
}
func (hcaptcha *HCaptcha) Script() template.HTML {
return `<script src="https://js.hcaptcha.com/1/api.js" async defer></script>`
}
func (hcaptcha *HCaptcha) FormField() template.HTML {
return hcaptcha.formField
}
func (hcaptcha *HCaptcha) CheckRequest(r *http.Request) (bool, error) {
err := r.ParseForm()
if err != nil {
return false, fmt.Errorf("error parsing form: %w", err)
}
token := r.PostForm.Get("x-captcha-response")
if token == "" {
slog.Debug("No hCaptcha response token in request")
return false, nil
}
form := url.Values{}
form.Set("secret", hcaptcha.secretKey)
form.Set("response", token)
res, err := http.PostForm(hcaptcha.siteverifyEndpoint, form)
if err != nil {
return false, fmt.Errorf("hCaptcha site verify error: %w", err)
}
var data struct {
Success bool `json:"success"`
Hostname string `json:"hostname"`
ErrorCodes []string `json:"error-codes"`
}
err = json.NewDecoder(res.Body).Decode(&data)
if err != nil {
return false, fmt.Errorf("hCaptcha site verify: JSON decode error: %w", err)
}
if res.StatusCode != http.StatusOK {
return false, fmt.Errorf("hCaptcha site verify: error codes %s, status %d", strings.Join(data.ErrorCodes, ", "), res.StatusCode)
}
result := data.Success && data.Hostname == r.URL.Hostname()
return result, nil
}
// NewHcaptcha returns an antispam implementation using the hCaptcha service.
func NewHcaptcha(siteKey string, secretKey string) *HCaptcha {
return newHcaptcha(siteKey, secretKey, "https://hcaptcha.com")
}
func newHcaptcha(siteKey string, secretKey string, endpoint string) *HCaptcha {
formField := `<div class="h-captcha" data-sitekey="` + template.HTMLEscapeString(siteKey) + `"></div>`
siteverifyEndpoint, err := url.JoinPath(endpoint, "siteverify")
if err != nil {
log.Panicf("error constructing siteverify endpoint: %v", err)
}
return &HCaptcha{
formField: template.HTML(formField),
secretKey: secretKey,
siteverifyEndpoint: siteverifyEndpoint,
}
}

View file

@ -0,0 +1,80 @@
package antispam
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func setupHcaptchaTest(t *testing.T) (*httptest.Server, *HCaptcha) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/siteverify" {
t.Errorf("Expecting call to `POST /siteverify`, got: %s %s", r.Method, r.URL.Path)
}
err := r.ParseForm()
if err != nil {
t.Errorf("Form parsing error: %s", err)
}
if secret := r.PostForm.Get("secret"); secret != "test" {
t.Errorf("Expecting hCaptcha secret key `test`, got: %s", secret)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if r.PostForm.Get("response") == "test" {
_, _ = w.Write([]byte(`{"success": true, "hostname": "localhost"}`))
} else {
_, _ = w.Write([]byte(`{"success": false, "hostname": "localhost", "error-codes": ["invalid-input-response"]}`))
}
}))
hcaptcha := newHcaptcha("test", "test", server.URL)
return server, hcaptcha
}
// TestHcaptchaEmpty checks that empty requests (without the hCaptcha token) are blocked.
func TestHcaptchaEmpty(t *testing.T) {
server, hcaptcha := setupHcaptchaTest(t)
defer server.Close()
req := httptest.NewRequest(http.MethodPost, "http://localhost/form", nil)
result, err := hcaptcha.CheckRequest(req)
if result || err != nil {
t.Errorf("hcaptcha.CheckRequest(req) = %v, %v, expecting false, nil", result, err)
}
}
// TestHcaptchaBadToken checks that invalid hCaptcha tokens are blocked.
func TestHcaptchaBadToken(t *testing.T) {
server, hcaptcha := setupHcaptchaTest(t)
defer server.Close()
form := url.Values{}
form.Add("x-captcha-response", "wrong")
req := httptest.NewRequest(http.MethodPost, "http://localhost/form", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
result, err := hcaptcha.CheckRequest(req)
if result || err != nil {
t.Errorf("hcaptcha.CheckRequest(req) = %v, %v, want false, nil", result, err)
}
}
// TestHcaptchaSuccess mocks a successful hCaptcha verification run.
func TestHcaptchaSuccess(t *testing.T) {
server, hcaptcha := setupHcaptchaTest(t)
defer server.Close()
form := url.Values{}
form.Add("x-captcha-response", "test")
req := httptest.NewRequest(http.MethodPost, "http://localhost/form", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
result, err := hcaptcha.CheckRequest(req)
if !result || err != nil {
t.Errorf("hcaptcha.CheckRequest(req) = %v, %v, want true, nil", result, err)
}
}

27
server/antispam/noop.go Normal file
View file

@ -0,0 +1,27 @@
package antispam
import (
"html/template"
"net/http"
)
type Noop struct{}
func (_ *Noop) Script() template.HTML {
return ""
}
func (_ *Noop) FormField() template.HTML {
return ""
}
func (_ *Noop) CheckRequest(_ *http.Request) (bool, error) {
return true, nil
}
var globalNoop = &Noop{}
// NewNoop returns an antispam implementation that allows every request.
func NewNoop() *Noop {
return globalNoop
}

76
server/csp/csp.go Normal file
View file

@ -0,0 +1,76 @@
package csp
import (
"crypto/rand"
"encoding/base64"
"log"
"net/http"
)
type cspKeyword uint
//go:generate stringer -type=cspKeyword -linecomment
const (
Self cspKeyword = iota // self
TrustedTypesEval // trusted-types-eval
InlineSpeculationRules // inline-speculation-rules
StrictDynamic // strict-dynamic
ReportSample // report-sample
)
type cspDirective struct {
values []string
keywords map[cspKeyword]struct{}
}
type Builder struct {
directives map[string]*cspDirective
nonce string
request *http.Request
}
func (csp *Builder) getDirective(directive string) *cspDirective {
d, ok := csp.directives[directive]
if !ok {
d = &cspDirective{
values: []string{},
keywords: map[cspKeyword]struct{}{},
}
csp.directives[directive] = d
}
return d
}
// WithValue adds a directive to the policy.
func (csp *Builder) WithValue(directive string, values ...string) *Builder {
csp.getDirective(directive).withValue(values...)
return csp
}
func (d *cspDirective) withValue(values ...string) *cspDirective {
d.values = append(d.values, values...)
return d
}
// WithKeyword adds a directive to the policy.
func (csp *Builder) WithKeyword(directive string, keyword cspKeyword) *Builder {
csp.getDirective(directive).keywords[keyword] = struct{}{}
return csp
}
// WithNonce adds a `nonce-…` value to the directive. The actual nonce value is
// generated when the policy is built.
func (csp *Builder) WithNonce(directive string) *Builder {
if csp.nonce != "" {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
log.Fatalf("Failed to generate nonce: %v", err)
}
csp.nonce = base64.RawURLEncoding.EncodeToString(b[:])
}
return csp.WithValue(directive, "nonce-"+csp.nonce)
}
func (csp *Builder) Build() string {
panic("TODO")
}

23
server/csp/request.go Normal file
View file

@ -0,0 +1,23 @@
package csp
import (
"context"
"net/http"
)
type cspKey struct{}
func Csp(r *http.Request) (*http.Request, *Builder) {
csp, ok := r.Context().Value(cspKey{}).(*Builder)
if !ok {
csp = &Builder{
request: r,
}
r = r.WithContext(context.WithValue(r.Context(), cspKey{}, csp))
}
return r, csp
}
func (csp *Builder) Write() {
csp.request.Header.Set("Content-Security-Policy", csp.Build())
}

25
server/csp/schema.go Normal file
View file

@ -0,0 +1,25 @@
package csp
const (
ScriptSrc = "script-src"
)
func (csp *Builder) WithScript(src ...string) *Builder {
return csp.WithValue(ScriptSrc, src...)
}
func (csp *Builder) WithScriptSelf() *Builder {
return csp.WithKeyword(ScriptSrc, Self)
}
func (csp *Builder) WithScriptTrustedTypesEval() *Builder {
return csp.WithKeyword(ScriptSrc, TrustedTypesEval)
}
func (csp *Builder) WithScriptInlineSpeculationRules() *Builder {
return csp.WithKeyword(ScriptSrc, InlineSpeculationRules)
}
func (csp *Builder) WithScriptNonce() *Builder {
return csp.WithNonce(ScriptSrc)
}

179
server/csrf.go Normal file
View file

@ -0,0 +1,179 @@
package main
import (
"context"
"html/template"
"log/slog"
"net/http"
"slices"
"github.com/gorilla/csrf"
)
func csrfError(w http.ResponseWriter, r *http.Request) {
slog.Debug("CSRF error", "reason", csrf.FailureReason(r))
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
}
type fetchMetadataKey struct{}
type FetchMetadataSite string
type FetchMetadataMode string
type FetchMetadataDest string
const (
SiteCrossSite FetchMetadataSite = "cross-site"
SiteNone FetchMetadataSite = "none"
SiteSameOrigin FetchMetadataSite = "same-origin"
SiteSameSite FetchMetadataSite = "same-site"
ModeCors FetchMetadataMode = "cors"
ModeNavigate FetchMetadataMode = "navigate"
ModeNoCors FetchMetadataMode = "no-cors"
ModeSameOrigin FetchMetadataMode = "same-origin"
ModeWebSocket FetchMetadataMode = "websocket"
DestAudio FetchMetadataDest = "audio"
DestAudioworklet FetchMetadataDest = "audioworklet"
DestDocument FetchMetadataDest = "document"
DestEmbed FetchMetadataDest = "embed"
DestEmpty FetchMetadataDest = "empty"
DestFencedframe FetchMetadataDest = "fencedframe"
DestFont FetchMetadataDest = "font"
DestFrame FetchMetadataDest = "frame"
DestIframe FetchMetadataDest = "iframe"
)
var (
fetchMetadataSites = []FetchMetadataSite{SiteCrossSite, SiteNone, SiteSameOrigin, SiteSameSite}
fetchMetadataModes = []FetchMetadataMode{ModeCors, ModeNavigate, ModeNoCors, ModeSameOrigin, ModeWebSocket}
fetchMetadataDests = []FetchMetadataDest{DestAudio, DestAudioworklet, DestDocument, DestEmbed, DestEmpty, DestFencedframe, DestFont, DestFrame, DestIframe}
)
type FetchMetadata struct {
// Site is the pre-validated value of the `Sec-Fetch-Site` header.
Site FetchMetadataSite
// Mode is the pre-validated value of the `Sec-Fetch-Mode` header.
Mode FetchMetadataMode
// Dest is the pre-validated value of the `Sec-Fetch-Dest` header.
Dest FetchMetadataDest
}
func (fm *FetchMetadata) IsSameOrigin() bool {
return fm != nil && fm.Site == "same-origin"
}
// IsFetch checks whether the request originated from a client- side `fetch()`
// (or similar) call.
func (fm *FetchMetadata) IsFetch() bool {
return fm != nil && fm.Site == "same-origin" && fm.Mode == "cors" && fm.Dest == "empty"
}
// IsLocalNavigation checks if the request originated from a top-level
// navigation, while optionally allowing frame embedding.
func (fm *FetchMetadata) IsLocalNavigation(allowFrames bool) bool {
if fm == nil || fm.Site != "same-origin" || fm.Mode != "navigate" {
return false
}
if allowFrames {
return fm.Dest == "document" || fm.Dest == "fencedframe" || fm.Dest == "frame" || fm.Dest == "iframe"
} else {
return fm.Dest == "document"
}
}
func validateFetchMetadata(r *http.Request) *FetchMetadata {
site := FetchMetadataSite(r.Header.Get("Sec-Fetch-Site"))
if site == "" {
return nil
}
if !slices.Contains(fetchMetadataSites, site) {
slog.Debug("Invalid Sec-Metadata-Site header value", "fetchMetadataSite", site)
return &FetchMetadata{Valid: false}
}
mode := r.Header.Get("Sec-Fetch-Mode")
if !slices.Contains(fetchMetadataModes, mode) {
slog.Debug("Invalid Sec-Metadata-Mode header value", "fetchMetadataMode", mode)
return &FetchMetadata{Valid: false}
}
dest := r.Header.Get("Sec-Fetch-Dest")
if _, ok := slices.BinarySearch(fetchMetadataDests, dest); !ok {
slog.Debug("Invalid Sec-Metadata-Dest header value", "fetchMetadataDest", dest)
return &FetchMetadata{Valid: false}
}
return &FetchMetadata{
Valid: true,
Site: site,
Mode: mode,
Dest: dest,
}
}
// GetFetchMetadata returns the validate fetch metadata headers of the request.
// This may be nil if there were none (for old browsers and other user agents)
// or they were invalid.
//
// Invalid headers should be ignored according to the specification:
// https://w3c.github.io/webappsec-fetch-metadata
func GetFetchMetadata(r *http.Request) *FetchMetadata {
fm, _ := r.Context().Value(fetchMetadataKey{}).(*FetchMetadata)
return fm
}
// AbortFetchMetadata terminates the request with a CSRF error indicating that
// fetch metadata headers did not comply.
func AbortFetchMetadata(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
r = r.WithContext(context.WithValue(ctx, "gorilla.csrf.Error", "CSRF metadata headers did not comply"))
csrfError(w, r)
}
// ProtectCsrf is an HTTP middleware for CSRF protection.
//
// It defaults to using Fetch Metadata Request Headers (prefixed with `Sec-Fetch`)
// to check whether requests were in fact first-party requests coming from the
// same HTTP origin. Only the site header is used other metadata must be checked
// in route implementations.
//
// Failing support for Fetch Metadata Request Headers, CSRF tokens from hidden
// form fields (see CsrfTemplateField) are used as a fallback.
func ProtectCsrf(next http.Handler, authKey []byte, middlewareOpts ...csrf.Option) http.HandlerFunc {
middleware := csrf.Protect(
authKey,
append(middlewareOpts, csrf.ErrorHandler(http.HandlerFunc(csrfError)))...,
)(next)
return func(w http.ResponseWriter, r *http.Request) {
fetchMetadata := validateFetchMetadata(r)
if fetchMetadata != nil {
/*
switch r.Method {
case "GET", "HEAD", "OPTIONS", "TRACE": // Idempotent (safe) methods [RFC7231, 4.2.2]
default:
if secFetchSiteHeader != "same-origin" {
ctx := r.Context()
r = r.WithContext(context.WithValue(ctx, "gorilla.csrf.Error", "fetch metadata headers indicate cross-origin request"))
csrfError(w, r)
return
}
}*/
r = r.WithContext(context.WithValue(r.Context(), fetchMetadataKey{}, fetchMetadata))
next.ServeHTTP(w, r)
} else {
middleware.ServeHTTP(w, r)
}
}
}
// CsrfTemplateField is a template helper for CSRF protection. Stick it into
// any form that submits to a CSRF-protected endpoint.
func CsrfTemplateField(r *http.Request) template.HTML {
if r.Header.Get("Sec-Fetch-Site") != "" {
return ""
}
return csrf.TemplateField(r)
}

134
server/csrf_test.go Normal file
View file

@ -0,0 +1,134 @@
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
var emptyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
type fetchMetadata struct {
site string
mode string
dest string
// Sec-Fetch-User is not tested here because it has limited availability:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-User#browser_compatibility
}
func addFetchMetadataHeaders(req *http.Request, m fetchMetadata) {
req.Header.Set("Sec-Fetch-Site", m.site)
req.Header.Set("Sec-Fetch-Mode", m.mode)
req.Header.Set("Sec-Fetch-Dest", m.dest)
}
// TestLookups makes sure that the lookup tables for valid fetch metadata header
// values are sorted correctly, so they can be used in binary searches.
func TestLookups(t *testing.T) {
t.Run("sites", func(t *testing.T) {
var s = slices.Sorted(slices.Values(fetchMetadataSites))
if slices.Compare(s, fetchMetadataSites) != 0 {
t.Error("fetchMetadataSites is not sorted")
}
})
t.Run("modes", func(t *testing.T) {
var s = slices.Sorted(slices.Values(fetchMetadataModes))
if slices.Compare(s, fetchMetadataModes) != 0 {
t.Error("fetchMetadataModes is not sorted")
}
})
t.Run("dests", func(t *testing.T) {
var s = slices.Sorted(slices.Values(fetchMetadataDests))
if slices.Compare(s, fetchMetadataDests) != 0 {
t.Error("fetchMetadataDests is not sorted")
}
})
}
// TestCsrfFetchHeaderCookies sees that the response headers and (crucially)
// cookies don't change when the client supports fetch metadata request
// headers.
func TestCsrfFetchHeaderCookies(t *testing.T) {
handler := ProtectCsrf(emptyHandler, []byte("test"))
req := httptest.NewRequest(http.MethodGet, "/", nil)
addFetchMetadataHeaders(req, fetchMetadata{"none", "navigate", "document"})
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
resp := w.Result()
if len(resp.Header) != 0 {
t.Errorf("Fetch metadata CSRF protection changed headers: %v", resp.Header)
}
}
func TestCsrfFetchHeaderAllows(t *testing.T) {
handler := ProtectCsrf(emptyHandler, []byte("test"))
run := func(t *testing.T, m string, fm fetchMetadata) {
req := httptest.NewRequest(m, "/", nil)
addFetchMetadataHeaders(req, fm)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("CSRF protection false block (status %d %s)", resp.StatusCode, resp.Status)
}
}
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
t.Run(method, func(t *testing.T) {
for _, fm := range []fetchMetadata{
{"same-origin", "navigate", "document"}, // Same origin top-level navigation / form submit
{"same-origin", "cors", "empty"}, // Same origin fetch() request
} {
t.Run(fmt.Sprintf("site-%s,mode-%s,dest-%s", fm.site, fm.mode, fm.dest), func(t *testing.T) {
run(t, method, fm)
})
}
})
}
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
t.Run(method, func(t *testing.T) {
for _, fm := range []fetchMetadata{
{"same-origin", "navigate", "document"}, // Same origin top-level navigation / form submit
{"same-origin", "cors", "empty"}, // Same origin fetch() request
} {
t.Run(fmt.Sprintf("site-%s,mode-%s,dest-%s", fm.site, fm.mode, fm.dest), func(t *testing.T) {
run(t, method, fm)
})
}
})
}
}
func TestCsrfFetchHeaderBlocks(t *testing.T) {
handler := ProtectCsrf(emptyHandler, []byte("test"))
run := func(t *testing.T, m string, fm fetchMetadata) {
req := httptest.NewRequest(m, "/", nil)
addFetchMetadataHeaders(req, fm)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("CSRF protection false allow (status %d %s)", resp.StatusCode, resp.Status)
}
}
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
t.Run(method, func(t *testing.T) {
for _, fm := range []fetchMetadata{
{"none", "navigate", "document"}, // Manual top-level navigation
{"cross-site", "navigate", "document"}, // Cross-site navigation
{"same-site", "navigate", "document"}, // eTLD-1 same-site navigation
} {
t.Run(fmt.Sprintf("site-%s,mode-%s,dest-%s", fm.site, fm.mode, fm.dest), func(t *testing.T) {
run(t, method, fm)
})
}
})
}
}

212
server/forms.go Normal file
View file

@ -0,0 +1,212 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"homepage/antispam"
"log/slog"
"mime/multipart"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"github.com/AlessandroSechi/zammad-go"
)
const formMarker = "<!-- FORM -->"
var namesRegex = regexp.MustCompile("^ *(.*) *([^ ]+) *$")
var multiSpaceRegex = regexp.MustCompile("\\s+")
type submission struct {
group string
category string
name string
email string
message string
attachments []submissionAttachment
information []struct {
key string
value string
}
}
type submissionAttachment struct {
multipart.File
*multipart.FileHeader
}
type toSubmission func(r *http.Request) (submission, error)
type form struct {
path string
toSubmission toSubmission
}
// CleanupInput removes unprintable characters like newlines.
func CleanupInput(s string) string {
s = strings.TrimSpace(s)
s = multiSpaceRegex.ReplaceAllString(s, " ")
return strings.Map(func(r rune) rune {
if unicode.IsPrint(r) {
return r
}
return -1
}, s)
}
func (form form) handleGet(w http.ResponseWriter, r *http.Request) {
logger := slog.Default().With("formPath", form.path)
ctx := r.Context()
templateBytes, err := os.ReadFile(path.Join(getRootPath(r), form.path, "index.html"))
if err != nil {
logger.ErrorContext(ctx, "Failed to read form template", "error", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
output := string(templateBytes)
markerIndex := strings.Index(output, formMarker)
if markerIndex != -1 {
csrfField := CsrfTemplateField(r)
antispamInstance := antispam.GetAntispam(r)
antispamField := string(antispamInstance.Script() + antispamInstance.FormField())
output = output[:markerIndex] + string(csrfField) + antispamField + output[markerIndex+len(formMarker):]
}
http.ServeContent(w, r, "form.html", time.Now(), strings.NewReader(output))
}
// getCustomerId uses the Zammad API to find a user ID to use as the
// submission's customer.
//
// Note the API also supports using `guess:{email}` as the custom ID when creating
// a ticket, according to the documentation [1]. However, that doesn't seem to
// work very reliably [2].
// [1]: https://docs.zammad.org/en/latest/api/ticket/index.html#create
// [2]: https://codeberg.org/angestoepselt/homepage/issues/141
func getCustomerId(submission *submission, zClient *zammad.Client) (int, error) {
nameParts := strings.Split(submission.name, " ")
if len(nameParts) == 1 {
nameParts = append([]string{"?"}, nameParts...)
}
// Create a new user. This will fail if it already exists.
user, err := zClient.UserCreate(zammad.User{
Firstname: strings.Join(nameParts[:len(nameParts)-1], " "),
Lastname: nameParts[len(nameParts)-1],
Email: submission.email,
})
if err == nil {
return user.ID, nil
}
var errResp *zammad.ErrorResponse
if !errors.As(err, &errResp) || !strings.Contains(errResp.Description, "already used") {
return 0, fmt.Errorf("creating new Zammad user failed: %w", err)
}
// Email is already associated with an existing user account. Find that one
// instead and use its ID.
userCandidates, err := zClient.UserSearch("email:"+submission.email, 1)
if err != nil {
return 0, fmt.Errorf("search for existing Zammad user failed: %w", err)
}
if len(userCandidates) == 0 {
return 0, errors.New("search for existing Zammad user yielded no results")
}
if len(userCandidates) > 1 {
return 0, errors.New("search for existing Zammad user yielded more than one result")
}
return userCandidates[0].ID, nil
}
func (form form) handlePost(w http.ResponseWriter, r *http.Request) {
logger := slog.Default().With("formPath", form.path)
ctx := r.Context()
if !GetFetchMetadata(r).IsLocalNavigation(false) {
AbortFetchMetadata(w, r)
return
}
err := r.ParseMultipartForm(128 << 20) // 128 KiB
if err != nil {
logger.DebugContext(ctx, "Failed to parse form template", "error", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
submission, err := form.toSubmission(r)
if err != nil {
logger.DebugContext(ctx, "Could not convert form content to submission", "error", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
}
contactNames := strings.Split(submission.name, " ")
type zammadUsersBody struct {
FirstName string `json:"firstname"`
LastName string `json:"lastname"`
Email string `json:"email"`
}
body := zammadUsersBody{
FirstName: "?",
LastName: submission.,
}
body, _ := json.Marshal(zammadUsersBody{
FirstName: "?",
LastName: contactNames[len(contactNames)-1],
Email: "",
})
resp = http.Post("TODO", "application/json", bytes.NewReader(body))
zClient, ok := r.Context().Value(zammadKey{}).(*zammad.Client)
if !ok {
logger.DebugContext(ctx, "Zammad integration unavailable")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
customerId, err := getCustomerId(&submission, zClient)
if err != nil {
logger.DebugContext(ctx, "Failed to match Zammad user as customer for submission", "error", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
zClientFrom := zammad.Client{
Client: zClient.Client,
Token: zClient.Token,
Url: zClient.Url,
FromFunc: func() string {
return strconv.Itoa(customerId)
},
}
zClientFrom.TicketCreate(zammad.Ticket{
Title: fmt.Sprintf("Kontaktformular %s %s", submission.name, form.path), // TODO form.path
Group: "", // TODO group
CustomerID: customerId,
Article: zammad.TicketArticle{
Type: "web",
Internal: false, // TODO this was true
ContentType: "text/plain",
Subject: "Kontaktformular-Anfrage " + submission.name,
Body: submission.message,
Attachments: []zammad.TicketArticleAttachment{},
},
})
http.ServeFile(w, r, path.Join(getRootPath(r), "kontakt/fertig/index.html"))
}
func (form form) setup(mux *http.ServeMux) {
mux.HandleFunc(http.MethodGet+" "+form.path+"/{$}", form.handleGet)
mux.HandleFunc(http.MethodPost+" "+form.path+"/{$}", form.handlePost)
}

13
server/go.mod Normal file
View file

@ -0,0 +1,13 @@
module homepage
go 1.25.4
require (
github.com/AlessandroSechi/zammad-go v0.0.0-20241027101934-e9e7d13e8bd5
github.com/gorilla/csrf v1.7.3
github.com/gorilla/schema v1.4.1
)
require github.com/gorilla/securecookie v1.1.2 // indirect
replace github.com/AlessandroSechi/zammad-go => /home/yannik/Repos/angestoepselt/zammad-go

10
server/go.sum Normal file
View file

@ -0,0 +1,10 @@
github.com/AlessandroSechi/zammad-go v0.0.0-20241027101934-e9e7d13e8bd5 h1:XKu2uuGhLEgqVxtUmRLevCb9ZM8JGYG4o3HRGRDvz04=
github.com/AlessandroSechi/zammad-go v0.0.0-20241027101934-e9e7d13e8bd5/go.mod h1:MnpvRP3H3st5kiSEHoOhBtgb7tHYSIOTD/JGGoz+sao=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=

73
server/main.go Normal file
View file

@ -0,0 +1,73 @@
package main
import (
"context"
"homepage/antispam"
"log/slog"
"net"
"net/http"
"os"
"path"
"github.com/AlessandroSechi/zammad-go"
)
type rootPathKey struct{}
type zammadKey struct{}
func getRootPath(r *http.Request) string {
if rootPath, ok := r.Context().Value(rootPathKey{}).(string); ok {
return rootPath
}
panic("missing root path in request context")
}
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})))
rootPath := path.Clean("../dist")
slog.Info("Found root path for static assets", "path", rootPath)
var zammadClient *zammad.Client
if zammadUrl := os.Getenv("ZAMMAD_URL"); zammadUrl != "" {
zammadClient = zammad.New(zammadUrl)
token := os.Getenv("ZAMMAD_TOKEN")
if token == "" {
slog.Error("No Zammad token available, set ZAMMAD_TOKEN accordingly")
os.Exit(1)
}
slog.Info("Using Zammad integration", "url", zammadUrl)
zammadClient.Token = token
} else {
slog.Warn("Zammad integration disabled, set ZAMMAD_URL to enable")
}
mux := http.NewServeMux()
setupSite(mux)
mux.Handle("/", http.FileServer(http.Dir(rootPath)))
var handler http.Handler
handler = ProtectCsrf(mux, []byte("fasdf"))
handler = antispam.WithAntispam(handler)
server := http.Server{
Addr: "localhost:8080",
Handler: handler,
BaseContext: func(_ net.Listener) context.Context {
ctx := context.Background()
ctx = context.WithValue(ctx, rootPathKey{}, rootPath)
if zammadClient != nil {
ctx = context.WithValue(ctx, zammadKey{}, zammadClient)
}
return ctx
},
}
slog.Info("Starting HTTP server", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil {
slog.Error("Failed to start HTTP server", "error", err)
}
}