mirror of
https://codeberg.org/angestoepselt/homepage.git
synced 2026-03-21 22:32:17 +00:00
212 lines
6.2 KiB
Go
212 lines
6.2 KiB
Go
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)
|
||
}
|