-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontact.go
More file actions
155 lines (132 loc) · 3.92 KB
/
contact.go
File metadata and controls
155 lines (132 loc) · 3.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
package main
import (
"fmt"
"log/slog"
"net/http"
"net/mail"
"net/smtp"
"os"
"strings"
"time"
)
var contactLimiter = newRateLimiter(3, time.Hour)
// contactSendSem caps in-flight SMTP sends. SMTP send does TLS + auth and
// can stall for tens of seconds; without a cap a flood of accepted
// submissions could fan out to enough concurrent goroutines to exhaust file
// descriptors or trip Gmail's per-account rate limit. The buffer is small
// because the contact form is low-volume by design.
var contactSendSem = make(chan struct{}, 2)
type contactPageData struct {
Sent bool
Error string
}
func handleContactPage(w http.ResponseWriter, r *http.Request) {
renderHTML(w, "contact.html", contactPageData{
Sent: r.URL.Query().Get("sent") == "1",
Error: r.URL.Query().Get("error"),
})
}
func handleContactSubmit(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if err := r.ParseForm(); err != nil {
redirectContact(w, r, "error=invalid")
return
}
// Honeypot: real users leave this blank; bots fill it in. Pretend success
// (and skip the rate limiter) so bots don't learn the trick.
if r.PostForm.Get("website") != "" {
slog.Info("contact honeypot", "ip", ip)
redirectContact(w, r, "sent=1")
return
}
if !contactLimiter.allow(ip) {
slog.Warn("contact rate limit exceeded", "ip", ip)
redirectContact(w, r, "error=rate")
return
}
message := strings.TrimSpace(r.PostForm.Get("message"))
replyEmail := strings.TrimSpace(r.PostForm.Get("reply_email"))
if len(message) < 5 || len(message) > 5000 {
redirectContact(w, r, "error=invalid")
return
}
if replyEmail != "" {
if _, err := mail.ParseAddress(replyEmail); err != nil {
redirectContact(w, r, "error=invalid")
return
}
}
select {
case contactSendSem <- struct{}{}:
go func() {
defer func() { <-contactSendSem }()
if err := sendContactEmail(replyEmail, message, ip); err != nil {
slog.Error("contact send", "error", err, "ip", ip)
}
}()
default:
// Send queue is full. The user still sees a success page (so we
// don't reveal capacity to attackers) but the message is dropped
// with a warning logged.
slog.Warn("contact send dropped: queue full", "ip", ip)
}
redirectContact(w, r, "sent=1")
}
func redirectContact(w http.ResponseWriter, r *http.Request, query string) {
http.Redirect(w, r, "/contact?"+query, http.StatusSeeOther)
}
func sendContactEmail(replyEmail, message, ip string) error {
smtpUser := os.Getenv("SMTP_USER")
smtpPass := os.Getenv("SMTP_PASS")
to := os.Getenv("CONTACT_TO_EMAIL")
if to == "" {
to = smtpUser
}
// Local-dev shortcut: if SMTP isn't configured, log the message so the
// form is testable end-to-end without credentials.
if smtpUser == "" || smtpPass == "" {
slog.Info("contact form skipped",
"reason", "smtp not configured",
"reply_email", replyEmail, "ip", ip, "message", message)
return nil
}
subject := "Train contact"
if preview := firstLine(message); preview != "" {
subject = "Train contact: " + truncate(preview, 60)
}
replyTo := smtpUser
displayReply := "(not provided)"
if replyEmail != "" {
replyTo = replyEmail
displayReply = replyEmail
}
submitted := time.Now().In(appLocation).Format("Mon 2 Jan 2006, 3:04 PM MST")
body := fmt.Sprintf(
"Reply email: %s\nIP: %s\nSubmitted: %s\n\n%s\n",
displayReply, ip, submitted, message,
)
msg := []byte(strings.Join([]string{
"From: Train <" + smtpUser + ">",
"To: " + to,
"Reply-To: " + replyTo,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
body,
}, "\r\n"))
auth := smtp.PlainAuth("", smtpUser, smtpPass, "smtp.gmail.com")
return smtp.SendMail("smtp.gmail.com:587", auth, smtpUser, []string{to}, msg)
}
func firstLine(s string) string {
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
return strings.TrimSpace(s[:i])
}
return strings.TrimSpace(s)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}