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