Contact form feature

This commit is contained in:
Jan-Lukas Else 2021-07-22 13:41:52 +02:00
parent 2cee446dff
commit bdbaf9b915
17 changed files with 198 additions and 23 deletions

View File

@ -71,6 +71,7 @@ type configBlog struct {
RandomPost *configRandomPost `mapstructure:"randomPost"`
Comments *configComments `mapstructure:"comments"`
Map *configGeoMap `mapstructure:"map"`
Contact *configContact `mapstructure:"contact"`
}
type configSection struct {
@ -151,6 +152,19 @@ type configGeoMap struct {
Path string `mapstructure:"path"`
}
type configContact struct {
Enabled bool `mapstructure:"enabled"`
Path string `mapstructure:"path"`
Title string `mapstructure:"title"`
Description string `mapstructure:"description"`
SMTPHost string `mapstructure:"smtpHost"`
SMTPPort int `mapstructure:"smtpPort"`
SMTPUser string `mapstructure:"smtpUser"`
SMTPPassword string `mapstructure:"smtpPassword"`
EmailFrom string `mapstructure:"emailFrom"`
EmailTo string `mapstructure:"emailTo"`
}
type configUser struct {
Nick string `mapstructure:"nick"`
Name string `mapstructure:"name"`

104
contact.go Normal file
View File

@ -0,0 +1,104 @@
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"net/smtp"
"strconv"
"strings"
"time"
"github.com/microcosm-cc/bluemonday"
)
const defaultContactPath = "/contact"
func (a *goBlog) serveContactForm(w http.ResponseWriter, r *http.Request) {
blog := r.Context().Value(blogContextKey).(string)
cc := a.cfg.Blogs[blog].Contact
a.render(w, r, templateContact, &renderData{
BlogString: blog,
Data: map[string]interface{}{
"title": cc.Title,
"description": cc.Description,
},
})
}
func (a *goBlog) sendContactSubmission(w http.ResponseWriter, r *http.Request) {
// Get form values
strict := bluemonday.StrictPolicy()
// Name
formName := strings.TrimSpace(strict.Sanitize(r.FormValue("name")))
// Email
formEmail := strings.TrimSpace(strict.Sanitize(r.FormValue("email")))
// Website
formWebsite := strings.TrimSpace(strict.Sanitize(r.FormValue("website")))
// Message
formMessage := strings.TrimSpace(strict.Sanitize(r.FormValue("message")))
if formMessage == "" {
a.serveError(w, r, "Message is empty", http.StatusBadRequest)
return
}
// Build message
var message bytes.Buffer
if formName != "" {
_, _ = fmt.Fprintf(&message, "Name: %s", formName)
_, _ = fmt.Fprintln(&message)
}
if formEmail != "" {
_, _ = fmt.Fprintf(&message, "Email: %s", formEmail)
_, _ = fmt.Fprintln(&message)
}
if formWebsite != "" {
_, _ = fmt.Fprintf(&message, "Website: %s", formWebsite)
_, _ = fmt.Fprintln(&message)
}
if message.Len() > 0 {
_, _ = fmt.Fprintln(&message)
}
_, _ = message.WriteString(formMessage)
// Send submission
blog := r.Context().Value(blogContextKey).(string)
if cc := a.cfg.Blogs[blog].Contact; cc != nil && cc.SMTPHost != "" && cc.EmailFrom != "" && cc.EmailTo != "" {
// Build email
var email bytes.Buffer
if ef := cc.EmailFrom; ef != "" {
_, _ = fmt.Fprintf(&email, "From: %s <%s>", defaultIfEmpty(a.cfg.Blogs[blog].Title, "GoBlog"), cc.EmailFrom)
_, _ = fmt.Fprintln(&email)
}
_, _ = fmt.Fprintf(&email, "To: %s", cc.EmailTo)
_, _ = fmt.Fprintln(&email)
if formEmail != "" {
_, _ = fmt.Fprintf(&email, "Reply-To: %s", formEmail)
_, _ = fmt.Fprintln(&email)
}
_, _ = fmt.Fprintf(&email, "Date: %s", time.Now().UTC().Format(time.RFC1123Z))
_, _ = fmt.Fprintln(&email)
_, _ = fmt.Fprintln(&email, "Subject: New message")
_, _ = fmt.Fprintln(&email)
_, _ = fmt.Fprintln(&email, message.String())
// Send email
auth := smtp.PlainAuth("", cc.SMTPUser, cc.SMTPPassword, cc.SMTPHost)
port := cc.SMTPPort
if port == 0 {
port = 587
}
if err := smtp.SendMail(cc.SMTPHost+":"+strconv.Itoa(port), auth, cc.EmailFrom, []string{cc.EmailTo}, email.Bytes()); err != nil {
log.Println("Failed to send mail:", err.Error())
}
} else {
log.Println("New contact submission not send as email, config missing")
}
// Send notification
a.sendNotification(message.String())
// Give feedback
a.render(w, r, templateContact, &renderData{
BlogString: blog,
Data: map[string]interface{}{
"sent": true,
},
})
}

View File

@ -216,4 +216,16 @@ blogs:
# Map
map:
enabled: true # Enable the map feature (shows a map with all post locations)
path: /map # (Optional) Set a custom path (relative to blog path)
path: /map # (Optional) Set a custom path (relative to blog path), default is /map
# Contact form
contact:
enabled: true # Enable a contact form
title: "Contact me!" # (Optional) Title to show above the form
description: "Feel free to send me a message" # (Optional) Description to show above the form
path: /contact # (Optional) Set a custom path (relative to blog path), default is /contact
smtpHost: smtp.example.com # SMTP host
smtpPort: 587 # (Optional) SMTP port, default is 587
smtpUser: mail@example.com # SMTP user
smtpPassword: secret # SMTP password
emailFrom: blog@example.com # Email sender
emailTo: mail@example.com # Email recipient

View File

@ -91,13 +91,13 @@ func (a *goBlog) serveLeaflet(basePath string) http.HandlerFunc {
switch path.Ext(fileName) {
case ".js":
w.Header().Set(contentType, contenttype.JS)
a.min.Write(w, contenttype.JSUTF8, fb)
_, _ = a.min.Write(w, contenttype.JSUTF8, fb)
case ".css":
w.Header().Set(contentType, contenttype.CSS)
a.min.Write(w, contenttype.CSSUTF8, fb)
_, _ = a.min.Write(w, contenttype.CSSUTF8, fb)
default:
w.Header().Set(contentType, http.DetectContentType(fb))
w.Write(fb)
_, _ = w.Write(fb)
}
}
}
@ -137,7 +137,7 @@ func (a *goBlog) proxyTiles(basePath string) http.HandlerFunc {
w.Header().Set(k, res.Header.Get(k))
}
w.WriteHeader(res.StatusCode)
io.Copy(w, res.Body)
_, _ = io.Copy(w, res.Body)
_ = res.Body.Close()
}
}

11
http.go
View File

@ -444,6 +444,17 @@ func (a *goBlog) buildRouter() (*chi.Mux, error) {
})
}
// Contact
if cc := blogConfig.Contact; cc != nil && cc.Enabled {
contactPath := blogConfig.getRelativePath(defaultIfEmpty(cc.Path, defaultContactPath))
r.Route(contactPath, func(r chi.Router) {
r.Use(privateModeHandler...)
r.Use(a.cache.cacheMiddleware, sbm)
r.Get("/", a.serveContactForm)
r.With(a.captchaMiddleware).Post("/", a.sendContactSubmission)
})
}
}
// Sitemap

View File

@ -115,7 +115,7 @@ form {
}
}
.fw-form {
form.fw {
@extend .fw;
input:not([type]), input[type="submit"], input[type="button"], input[type="text"], input[type="email"], input[type="url"], input[type="password"], input[type="file"], textarea, select {

View File

@ -40,6 +40,7 @@ const (
templateWebmentionAdmin = "webmentionadmin"
templateBlogroll = "blogroll"
templateGeoMap = "geomap"
templateContact = "contact"
)
func (a *goBlog) initRendering() error {

View File

@ -159,7 +159,7 @@ footer * {
display: inline;
}
.fw, img, audio, .fw-form, .fw-form input:not([type]), .fw-form input[type=submit], .fw-form input[type=button], .fw-form input[type=text], .fw-form input[type=email], .fw-form input[type=url], .fw-form input[type=password], .fw-form input[type=file], .fw-form textarea, .fw-form select {
.fw, img, audio, form.fw, form.fw input:not([type]), form.fw input[type=submit], form.fw input[type=button], form.fw input[type=text], form.fw input[type=email], form.fw input[type=url], form.fw input[type=password], form.fw input[type=file], form.fw textarea, form.fw select {
width: 100%;
}

View File

@ -6,14 +6,14 @@
<main>
<h1>{{ string .Blog.Lang "captcha" }}</h1>
<img src="/captcha/{{ .Data.captchaid }}.png" class="captchaimg">
<form class="fw-form p" method="post">
<form class="fw p" method="post">
<input type="hidden" name="captchaaction" value="captcha">
<input type="hidden" name="captchamethod" value="{{ .Data.captchamethod }}">
<input type="hidden" name="captchaheaders" value="{{ .Data.captchaheaders }}">
<input type="hidden" name="captchabody" value="{{ .Data.captchabody }}">
<input type="hidden" name="captchaid" value="{{ .Data.captchaid }}">
<input type="text" name="digits" placeholder="{{ string .Blog.Lang "captchainstructions" }}" required>
<input class="fw" type="submit" value="{{ string .Blog.Lang "submit" }}">
<input type="submit" value="{{ string .Blog.Lang "submit" }}">
</form>
</main>
{{ end }}

25
templates/contact.gohtml Normal file
View File

@ -0,0 +1,25 @@
{{ define "title" }}
<title>{{ .Data.Name }}</title>
{{ end }}
{{ define "main" }}
<main>
{{ if .Data.sent }}
<p>{{ string .Blog.Lang "messagesent" }}</p>
{{ else }}
{{ with .Data.title }}<h1>{{ . }}</h1>{{ end }}
{{ with .Data.description }}{{ md . }}{{ end }}
<form class="fw p" method="post">
<input type="text" name="name" placeholder="{{ string .Blog.Lang "nameopt" }}">
<input type="url" name="website" placeholder="{{ string .Blog.Lang "websiteopt" }}">
<input type="email" name="email" placeholder="{{ string .Blog.Lang "emailopt" }}">
<textarea name="message" required placeholder="{{ string .Blog.Lang "message" }}"></textarea>
<input type="submit" value="{{ string .Blog.Lang "contactsend" }}">
</form>
{{ end }}
</main>
{{ end }}
{{ define "contact" }}
{{ template "base" . }}
{{ end }}

View File

@ -6,7 +6,7 @@
<main>
<h1>{{ string .Blog.Lang "editor" }}</h1>
<h2>{{ string .Blog.Lang "create" }}</h2>
<form class="fw-form p" method="post">
<form class="fw p" method="post">
<input type="hidden" name="h" value="entry">
<textarea name="content" class="monospace h400p formcache" id="create-input">---
status: draft
@ -21,7 +21,7 @@ tags:
<input type="submit" value="{{ string .Blog.Lang "create" }}">
</form>
<h2 id="update">{{ string .Blog.Lang "update" }}</h2>
<form class="fw-form p" method="post" action="#update">
<form class="fw p" method="post" action="#update">
{{ if .Data.UpdatePostURL }}
<input type="hidden" name="editoraction" value="updatepost">
<input type="hidden" name="url" value="{{ .Data.UpdatePostURL }}">
@ -33,7 +33,7 @@ tags:
<input type="submit" value="{{ string .Blog.Lang "update" }}">
</form>
<h2 id="delete">{{ string .Blog.Lang "delete" }}</h2>
<form class="fw-form p" method="post" action="#delete">
<form class="fw p" method="post" action="#delete">
{{ if .Data.DeleteURL }}
<input type="hidden" name="action" value="delete">
<input type="url" name="url" placeholder="URL" value="{{ .Data.DeleteURL }}">
@ -49,14 +49,14 @@ tags:
<p><a href="{{ .Blog.RelativePath "/editor/private" }}">{{ string .Blog.Lang "privateposts" }}</a></p>
<p><a href="{{ .Blog.RelativePath "/editor/unlisted" }}">{{ string .Blog.Lang "unlistedposts" }}</a></p>
<h2>{{ string .Blog.Lang "upload" }}</h2>
<form class="fw-form p" method="post" enctype="multipart/form-data">
<form class="fw p" method="post" enctype="multipart/form-data">
<input type="hidden" name="editoraction" value="upload">
<input type="file" name="file">
<input type="submit" value="{{ string .Blog.Lang "upload" }}">
</form>
<p><a href="{{ .Blog.RelativePath "/editor/files" }}">{{ string .Blog.Lang "mediafiles" }}</a></p>
<h2>{{ string .Blog.Lang "location" }}</h2>
<form class="fw-form p">
<form class="fw p">
<input id="geobtn" type="button" value="{{ string .Blog.Lang "locationget" }}" data-failed="{{ string .Blog.Lang "locationfailed" }}" data-notsupported="{{ string .Blog.Lang "locationnotsupported" }}">
<input id="geostatus" type="text" class="hide" readonly>
</form>

View File

@ -7,7 +7,7 @@
{{ $blog := .Blog }}
<h2>{{ string .Blog.Lang "mediafiles" }}</h2>
{{ if .Data.Files }}
<form class="fw-form p" method="post">
<form class="fw p" method="post">
<select name="filename">
{{ $uses := .Data.Uses }}
{{ range $i, $file := .Data.Files }}

View File

@ -14,18 +14,18 @@
{{ end }}
</ul>
{{ end }}
<form class="fw-form p" method="post" action="/webmention">
<form class="fw p" method="post" action="/webmention">
<label for="wm-source" class="p">{{ string .Blog.Lang "interactionslabel" }}</label>
<input id="wm-source" type="url" name="source" placeholder="URL" required>
<input type="hidden" name="target" value="{{ .Canonical }}">
<input class="fw" type="submit" value="{{ string .Blog.Lang "send" }}">
<input type="submit" value="{{ string .Blog.Lang "send" }}">
</form>
<form class="fw-form p" method="post" action="{{ .Blog.RelativePath "/comment" }}">
<form class="fw p" method="post" action="{{ .Blog.RelativePath "/comment" }}">
<input type="hidden" name="target" value="{{ .Canonical }}">
<input type="text" name="name" placeholder="{{ string .Blog.Lang "nameopt" }}">
<input type="url" name="website" placeholder="{{ string .Blog.Lang "websiteopt" }}">
<textarea name="comment" required placeholder="{{ string .Blog.Lang "comment" }}"></textarea>
<input class="fw" type="submit" value="{{ string .Blog.Lang "docomment" }}">
<input type="submit" value="{{ string .Blog.Lang "docomment" }}">
</form>
</details>
{{ end }}

View File

@ -5,7 +5,7 @@
{{ define "main" }}
<main>
<h1>{{ string .Blog.Lang "login" }}</h1>
<form class="fw-form p" method="post">
<form class="fw p" method="post">
<input type="hidden" name="loginaction" value="login">
<input type="hidden" name="loginmethod" value="{{ .Data.loginmethod }}">
<input type="hidden" name="loginheaders" value="{{ .Data.loginheaders }}">
@ -15,7 +15,7 @@
{{ if .Data.totp }}
<input type="text" name="token" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="{{ string .Blog.Lang "totp" }}">
{{ end }}
<input class="fw" type="submit" value="{{ string .Blog.Lang "login" }}">
<input type="submit" value="{{ string .Blog.Lang "login" }}">
</form>
{{ include "author" . }}
</main>

View File

@ -9,9 +9,9 @@
{{ if (or .Blog.Search.Title .Blog.Search.Description) }}
<hr>
{{ end }}
<form class="fw-form p" method="post">
<form class="fw p" method="post">
<input type="text" name="q" {{ with .Blog.Search.Placeholder }}placeholder="{{ . }}"{{ end }}>
<input class="fw" type="submit" value="🔍 {{ string .Blog.Lang "search" }}">
<input type="submit" value="🔍 {{ string .Blog.Lang "search" }}">
</form>
</main>
{{ end }}

View File

@ -5,12 +5,14 @@ comments: "Kommentare"
confirmdelete: "Löschen bestätigen"
connectedviator: "Verbunden über Tor."
connectviator: "Über Tor verbinden."
contactsend: "Senden"
create: "Erstellen"
delete: "Löschen"
docomment: "Kommentieren"
download: "Herunterladen"
drafts: "Entwürfe"
editor: "Editor"
emailopt: "E-Mail (optional)"
fileuses: "Datei-Verwendungen"
interactions: "Interaktionen & Kommentare"
interactionslabel: "Hast du eine Antwort hierzu veröffentlicht? Füge hier die URL ein."
@ -21,6 +23,8 @@ locationfailed: "Abfragen des Standorts fehlgeschlagen"
locationget: "Standort abfragen"
locationnotsupported: "Die Standort-API wird von diesem Browser nicht unterstützt"
mediafiles: "Medien-Dateien"
message: "Nachricht"
messagesent: "Nachricht gesendet"
next: "Weiter"
nofiles: "Keine Dateien"
nolocations: "Keine Posts mit Standorten"

View File

@ -10,12 +10,14 @@ comments: "Comments"
confirmdelete: "Confirm deletion"
connectedviator: "Connected via Tor."
connectviator: "Connect via Tor."
contactsend: "Send"
create: "Create"
delete: "Delete"
docomment: "Comment"
download: "Download"
drafts: "Drafts"
editor: "Editor"
emailopt: "Email (optional)"
feed: "Feed"
fileuses: "file uses"
indieauth: "IndieAuth"
@ -30,6 +32,8 @@ locationnotsupported: "The location API is not supported by this browser"
login: "Login"
logout: "Logout"
mediafiles: "Media files"
message: "Message"
messagesent: "Message sent"
nameopt: "Name (optional)"
next: "Next"
nofiles: "No files"