Source code for the Codeberg e.V. registration web service. https://join.codeberg.org
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

449 lines
13 KiB

package main
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"net/smtp"
"net/url"
"os"
"os/exec"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/snapcore/go-gettext"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/renderer/html"
"golang.org/x/text/language"
)
var environmentDefaults = map[string]string{
"MAIL_REGISTRATION_RECIPIENT": "registration@codeberg.org",
"MAIL_FROM": "codeberg@codeberg.org",
"MAIL_PASSWORD": "",
"MAIL_HOST": "localhost",
"MAIL_PORT": "587",
}
func (serverCtx *serverContext) getClientIP() string {
hdr := serverCtx.req.Header["X-Forwarded-For"]
if hdr == nil {
return serverCtx.req.RemoteAddr
}
return strings.Join(hdr, ";")
}
func (serverCtx *serverContext) logLine(msg string) {
fmt.Printf("%s: %s\n", serverCtx.req.RemoteAddr, msg)
}
func (serverCtx *serverContext) serverError(msg string) {
serverCtx.logLine(fmt.Sprintf("ERROR: %s", msg))
http.Error(serverCtx.writer, "Internal Server Error", http.StatusInternalServerError)
}
// Use gettext-compatible translation files - for documentation see https://github.com/snapcore/go-gettext
// Basically, it expects them in locales/{de,en,...}/messages.mo.
// The .mo files can be generated from the .po files using "go generate" (its command is defined in the next line).
//go:generate find locales -name "*.po" -execdir msgfmt "{}" ";"
var localeDomain = &gettext.TextDomain{
Name: "messages",
LocaleDir: "locales",
PathResolver: simpleLocaleResolver,
}
func simpleLocaleResolver(root, locale, domain string) string {
return path.Join(root, locale, fmt.Sprintf("%s.mo", domain))
}
func localeSelector(req *http.Request) gettext.Catalog {
localesToTry := []string{}
// Parse the Accept-Language header - for documentation see https://pkg.go.dev/golang.org/x/text/language#ParseAcceptLanguage
tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
if tags != nil {
for i := 0; i < len(tags); i++ {
base, _ := tags[i].Base()
localesToTry = append(localesToTry, base.String())
}
}
// Use the first one from the list (which is ordered correctly by the language package), and fall back to English
localesToTry = append(localesToTry, "en")
return localeDomain.Locale(localesToTry...)
}
type serverContext struct {
writer http.ResponseWriter
req *http.Request
locale gettext.Catalog
}
// For passing around to all our handlers and things
func newServerContext(writer http.ResponseWriter, req *http.Request) *serverContext {
return &serverContext{writer, req, localeSelector(req)}
}
// For rendering variables in templates
type renderingContext map[string]interface{}
type handler struct {
// If method and path match, we call handler
method string
path string
handler func(*serverContext, renderingContext)
}
// This is all our little application does
var handlers = [...]handler{
{"POST", "/post", handlePost},
{"GET", "/thanks", renderThanks},
{"GET", "/", renderForm},
}
type validator struct {
required bool
validator func(locale gettext.Catalog, datum string) error
}
var validators = map[string]validator{
"email-address": {true, checkEmail},
"addr1": {true, nil},
"zipcode": {true, nil},
"city": {true, nil},
"country": {true, nil},
"iban": {true, nil},
}
const (
REQUIRED_FIELD = "This field is required"
BAD_EMAIL = "Please enter a valid email address"
)
func errorMessage(locale gettext.Catalog, code string) error {
return fmt.Errorf(locale.Gettext(code))
}
// Checks datum and returns an error
func (val *validator) checkField(serverCtx *serverContext, datum string) error {
if val.required && datum == "" {
return errorMessage(serverCtx.locale, REQUIRED_FIELD)
}
if val.validator != nil {
return val.validator(serverCtx.locale, datum)
}
return nil
}
var emailPattern = regexp.MustCompile(`^\S+@\S+$`)
func checkEmail(locale gettext.Catalog, email string) error {
if !emailPattern.MatchString(email) {
return errorMessage(locale, BAD_EMAIL)
}
return nil
}
var md = goldmark.New(
goldmark.WithRendererOptions(
html.WithHardWraps(),
),
)
func renderTemplate(serverCtx *serverContext, fname string, renderCtx renderingContext) {
templ := template.New(fname)
templ.Funcs(map[string]interface{}{
"Gettext": serverCtx.locale.Gettext,
"NGettext": serverCtx.locale.NGettext,
"NPGettext": serverCtx.locale.NPGettext,
"PGettext": serverCtx.locale.PGettext,
"Markdown": func(input string) template.HTML {
builder := &strings.Builder{}
err := md.Convert([]byte(input), builder)
if err != nil {
panic(err)
}
return template.HTML(builder.String())
},
"Options": func(input ...string) []string {
return input
},
})
templ, err := templ.ParseFiles("templates/" + fname)
if err != nil {
serverCtx.logLine("Error parsing template file: " + err.Error())
panic(err)
}
err = templ.Execute(serverCtx.writer, renderCtx)
if err != nil {
serverCtx.logLine("Error usign template: " + err.Error())
panic(err)
}
}
func renderForm(serverCtx *serverContext, renderCtx renderingContext) {
serverCtx.logLine("Loaded form")
renderTemplate(serverCtx, "form.tmpl", renderCtx)
}
// Convert a "money" string (i.e. with separators for thousands and decimals)
// to a float
func parseMoney(strAmount string) (float64, error) {
pat := regexp.MustCompile("[,'.]")
fields := pat.Split(strAmount, -1)
n := len(fields)
if n == 0 {
return 0, fmt.Errorf("bad amount of money")
}
if n == 1 {
return strconv.ParseFloat(strAmount, 64)
}
var fraction string
if len(fields[n-1]) == 2 {
fraction = fields[n-1]
} else {
fraction = "00"
}
var reformatted string
for _, f := range fields[:n-1] {
reformatted += f
}
reformatted = fmt.Sprintf("%s.%s", reformatted, fraction)
return strconv.ParseFloat(reformatted, 64)
}
// Given the post data return a flattened and cleaned-up version of it and any errors.
func validate(serverCtx *serverContext, postData url.Values) (map[string]string, map[string]string) {
cleanedData := make(map[string]string)
errors := make(map[string]string)
for k, v := range postData {
if len(v) != 1 {
continue
}
if validator, exists := validators[k]; exists {
err := validator.checkField(serverCtx, v[0])
if err != nil {
errors[k] = k + " : " + err.Error()
continue
}
}
cleanedData[k] = v[0]
}
var frequency float64
switch cleanedData["payment-method"] {
case "sepa-yearly":
frequency = 12
case "sepa-half-yearly":
frequency = 6
case "sepa-quarterly":
frequency = 3
case "sepa-monthly":
frequency = 1
default:
frequency = 12
}
// check whether this is an active Member
activeMembership := cleanedData["membershipType"] == "activeMember"
// check contribution
contributionString := cleanedData["contribution"]
contribution, err := parseMoney(contributionString)
annualContribution := contribution * 12 / frequency
if err != nil || contribution < 10 || (activeMembership && annualContribution < 24) {
errors["contribution"] = fmt.Sprintf("Form error: contribution %g (%g p.a.)", contribution, annualContribution)
}
cleanedData["timestamp"] = time.Now().String()
cleanedData["registrationClientIP"] = serverCtx.getClientIP()
return cleanedData, errors
}
func startRegistration(serverCtx *serverContext, json []byte) error {
recipient := getEnv("MAIL_REGISTRATION_RECIPIENT")
p := "gpg --no-default-keyring --keyring /etc/reg-server/public-key.asc.gpg --trust-model always --encrypt --armor --recipient " + recipient
serverCtx.logLine(fmt.Sprintf("Executing '%s'", p))
cmd := exec.Command("bash", "-c", p)
stdin, _ := cmd.StdinPipe()
io.WriteString(stdin, string(json))
stdin.Close()
output, err := cmd.CombinedOutput()
if err != nil {
serverCtx.logLine("gpg command failed: " + err.Error() + " / " + string(output))
return err
}
return sendMail(serverCtx, recipient, "User registration", string(output))
}
func sendRegistrationEmail(serverCtx *serverContext, formData map[string]string) error {
msg := getMailText(formData)
return sendMail(serverCtx, formData["email-address"], "Welcome to Codeberg", msg)
}
func getMailText(formData map[string]string) string {
keys := make([]string, 0, len(formData))
for k := range formData {
keys = append(keys, k)
}
sort.Strings(keys)
msg := "Hello "
if formData["memberType"] == "corporate" {
msg += formData["organization"] + "!\n\n"
} else {
msg += formData["first-name"] + "!\n\n"
}
msg += "We are happy to welcome you as a member of Codeberg e.V.\n"
msg += "The following record will be stored in the membership database:\n\n"
msg += " {\n"
for _, key := range keys {
comment := ""
if formData[key] != "" {
if key == "iban" {
s := []rune(formData[key])
for i := 4; i < len(s)-5; i++ {
s[i] = '*'
}
formData[key] = string(s)
comment = "/* hidden here for privacy reasons */"
}
if key == "frequency" {
comment = "/* " + formData["contribution"] + " EUR contribution every " + formData[key] + " month(s) */"
}
if key == "registrationClientIP" {
comment = "/* client IP address at registration. Used to detect and block abuse of online registration system */"
}
msg += " \"" + key + "\" : \"" + formData[key] + "\", " + comment + "\n"
}
}
msg += " }\n\n"
msg += "Please review this data and let us know if anything went wrong.\n\n"
msg += "The next SEPA transfer will be manually initiated in next few days.\n"
msg += `If this won't work you will be notified, we can try other transfer methods
(members in US, Canada, New Zealand, etc contribute via paypal or wire transfer,
in other non-EU countries like Switzerland SEPA works without problems).`
msg += "\n\n"
msg += `Please note that membership in the gitea account group "Members" on
codeberg.org is not automatic (this group enables access to Codeberg e.V.'s
internal repos and discussion between Codeberg e.V. members therein).
For privacy reasons we add members on request (your membership is visible
to other members). If you like to join, please send an email to codeberg@codeberg.org
and tell us your username.`
msg += "\n\n"
msg += "Codeberg e.V.\n\n"
return msg
}
// get the environment variable with the given key, return the default string if the environment variable is not set
func getEnv(key string) string {
if os.Getenv(key) != "" {
return os.Getenv(key)
}
return environmentDefaults[key]
}
func sendMail(serverCtx *serverContext, to, subject, msg string) error {
from := getEnv("MAIL_FROM")
password := getEnv("MAIL_PASSWORD")
toList := []string{to}
host := getEnv("MAIL_HOST")
port := getEnv("MAIL_PORT")
bodyComposite := "From: " + from + "\n" +
"To: " + to + "\n" +
"Subject: " + subject + "\n\n" +
msg
body := []byte(bodyComposite)
auth := smtp.PlainAuth("", from, password, host)
err := smtp.SendMail(host+":"+port, auth, from, toList, body)
if err != nil {
serverCtx.logLine("Could not send mail: " + err.Error())
return err
}
return nil
}
func handlePost(serverCtx *serverContext, _ renderingContext) {
serverCtx.logLine("handlePost")
writer, req := serverCtx.writer, serverCtx.req
err := req.ParseForm()
if err != nil {
serverCtx.serverError("Unable to parse form: " + err.Error())
return
}
formData, errors := validate(serverCtx, req.PostForm)
if len(errors) != 0 {
renderCtx := make(renderingContext)
renderCtx["errors"] = errors
renderForm(serverCtx, renderCtx)
for k, v := range errors {
serverCtx.logLine("Error in posted form: " + k + " Value: " + v)
}
return
}
jsonString, err := json.Marshal(formData)
if err != nil {
serverCtx.serverError("Bad JSON: " + err.Error())
return
}
err = startRegistration(serverCtx, jsonString)
if err != nil {
serverCtx.serverError(fmt.Sprintf("startRegistration failed: %s", err))
} else {
err = sendRegistrationEmail(serverCtx, formData)
if err != nil {
serverCtx.serverError(fmt.Sprintf("sendRegistrationEmail failed: %s", err))
} else {
http.Redirect(writer, req, "/thanks", http.StatusFound)
serverCtx.logLine(fmt.Sprintf("Successful registration from <%s>", formData["email-address"]))
}
}
}
// renders the thanks template at the end of the registration process
func renderThanks(serverCtx *serverContext, renderCtx renderingContext) {
serverCtx.logLine("renderThanks")
renderTemplate(serverCtx, "thanks.tmpl", renderCtx)
}
func dispatcher(writer http.ResponseWriter, req *http.Request) {
serverCtx := newServerContext(writer, req)
defer func() {
if r := recover(); r != nil {
serverCtx.serverError(fmt.Sprintf("Unhandled exception: %v", r))
return
}
}()
for i := range handlers {
handler := &handlers[i]
if handler.method == req.Method &&
strings.HasPrefix(req.URL.Path, handler.path) {
handler.handler(serverCtx, nil)
return
}
}
http.NotFound(writer, req)
}
func main() {
fmt.Println("Listening on port 5000")
http.HandleFunc("/", dispatcher)
fmt.Printf("http.ListenAndServe: %v\n", http.ListenAndServe(":5000", nil))
}