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