homepage/server/forms.go
2026-01-13 21:31:43 +01:00

212 lines
6.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}