Add support for subscribing to comment threads

This commit is contained in:
Noah 2017-11-26 18:52:14 -08:00
parent 725437d06f
commit 527e995c1c
19 changed files with 626 additions and 234 deletions

View File

@ -38,25 +38,40 @@ func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
redisPort, _ := strconv.Atoi(r.FormValue("redis-port")) redisPort, _ := strconv.Atoi(r.FormValue("redis-port"))
redisDB, _ := strconv.Atoi(r.FormValue("redis-db")) redisDB, _ := strconv.Atoi(r.FormValue("redis-db"))
mailPort, _ := strconv.Atoi(r.FormValue("mail-port"))
form := &forms.Settings{ form := &forms.Settings{
Title: r.FormValue("title"), Title: r.FormValue("title"),
AdminEmail: r.FormValue("admin-email"), AdminEmail: r.FormValue("admin-email"),
URL: r.FormValue("url"),
RedisEnabled: r.FormValue("redis-enabled") == "true", RedisEnabled: r.FormValue("redis-enabled") == "true",
RedisHost: r.FormValue("redis-host"), RedisHost: r.FormValue("redis-host"),
RedisPort: redisPort, RedisPort: redisPort,
RedisDB: redisDB, RedisDB: redisDB,
RedisPrefix: r.FormValue("redis-prefix"), RedisPrefix: r.FormValue("redis-prefix"),
MailEnabled: r.FormValue("mail-enabled") == "true",
MailSender: r.FormValue("mail-sender"),
MailHost: r.FormValue("mail-host"),
MailPort: mailPort,
MailUsername: r.FormValue("mail-username"),
MailPassword: r.FormValue("mail-password"),
} }
// Copy form values into the settings struct for display, in case of // Copy form values into the settings struct for display, in case of
// any validation errors. // any validation errors.
settings.Site.Title = form.Title settings.Site.Title = form.Title
settings.Site.AdminEmail = form.AdminEmail settings.Site.AdminEmail = form.AdminEmail
settings.Site.URL = form.URL
settings.Redis.Enabled = form.RedisEnabled settings.Redis.Enabled = form.RedisEnabled
settings.Redis.Host = form.RedisHost settings.Redis.Host = form.RedisHost
settings.Redis.Port = form.RedisPort settings.Redis.Port = form.RedisPort
settings.Redis.DB = form.RedisDB settings.Redis.DB = form.RedisDB
settings.Redis.Prefix = form.RedisPrefix settings.Redis.Prefix = form.RedisPrefix
settings.Mail.Enabled = form.MailEnabled
settings.Mail.Sender = form.MailSender
settings.Mail.Host = form.MailHost
settings.Mail.Port = form.MailPort
settings.Mail.Username = form.MailUsername
settings.Mail.Password = form.MailPassword
err := form.Validate() err := form.Validate()
if err != nil { if err != nil {
v.Error = err v.Error = err

View File

@ -3,6 +3,7 @@ package core
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"html/template" "html/template"
"net/http" "net/http"
"sort" "sort"
@ -11,6 +12,7 @@ import (
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/kirsle/blog/core/models/comments"
"github.com/kirsle/blog/core/models/posts" "github.com/kirsle/blog/core/models/posts"
"github.com/kirsle/blog/core/models/users" "github.com/kirsle/blog/core/models/users"
"github.com/urfave/negroni" "github.com/urfave/negroni"
@ -21,6 +23,7 @@ type PostMeta struct {
Post *posts.Post Post *posts.Post
Rendered template.HTML Rendered template.HTML
Author *users.User Author *users.User
NumComments int
IndexView bool IndexView bool
Snipped bool Snipped bool
} }
@ -201,10 +204,17 @@ func (b *Blog) PartialIndex(w http.ResponseWriter, r *http.Request,
author = users.DeletedUser() author = users.DeletedUser()
} }
// Count the comments on this post.
var numComments int
if thread, err := comments.Load(fmt.Sprintf("post-%d", post.ID)); err == nil {
numComments = len(thread.Comments)
}
view = append(view, PostMeta{ view = append(view, PostMeta{
Post: post, Post: post,
Rendered: rendered, Rendered: rendered,
Author: author, Author: author,
NumComments: numComments,
}) })
} }
@ -286,7 +296,7 @@ func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string)
// RenderPost renders a blog post as a partial template and returns the HTML. // RenderPost renders a blog post as a partial template and returns the HTML.
// If indexView is true, the blog headers will be hyperlinked to the dedicated // If indexView is true, the blog headers will be hyperlinked to the dedicated
// entry view page. // entry view page.
func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML { func (b *Blog) RenderPost(p *posts.Post, indexView bool, numComments int) template.HTML {
// Look up the author's information. // Look up the author's information.
author, err := users.LoadReadonly(p.AuthorID) author, err := users.LoadReadonly(p.AuthorID)
if err != nil { if err != nil {
@ -332,6 +342,7 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
Author: author, Author: author,
IndexView: indexView, IndexView: indexView,
Snipped: snipped, Snipped: snipped,
NumComments: numComments,
} }
output := bytes.Buffer{} output := bytes.Buffer{}
err = t.Execute(&output, meta) err = t.Execute(&output, meta)

View File

@ -2,9 +2,10 @@ package core
import ( import (
"bytes" "bytes"
"fmt" "errors"
"html/template" "html/template"
"net/http" "net/http"
"net/mail"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
@ -17,24 +18,19 @@ import (
// CommentRoutes attaches the comment routes to the app. // CommentRoutes attaches the comment routes to the app.
func (b *Blog) CommentRoutes(r *mux.Router) { func (b *Blog) CommentRoutes(r *mux.Router) {
r.HandleFunc("/comments", b.CommentHandler) r.HandleFunc("/comments", b.CommentHandler)
r.HandleFunc("/comments/edit", b.EditCommentHandler) r.HandleFunc("/comments/subscription", b.SubscriptionHandler)
r.HandleFunc("/comments/delete", b.DeleteCommentHandler) r.HandleFunc("/comments/quick-delete", b.QuickDeleteHandler)
} }
// CommentMeta is the template variables for comment threads. // CommentMeta is the template variables for comment threads.
type CommentMeta struct { type CommentMeta struct {
IsAuthenticated bool NewComment comments.Comment
ID string ID string
OriginURL string // URL where original comment thread appeared OriginURL string // URL where original comment thread appeared
Subject string // email subject Subject string // email subject
Thread *comments.Thread Thread *comments.Thread
Authors map[int]*users.User Authors map[int]*users.User
CSRF string CSRF string
// Cached name and email of the user.
Name string
Email string
EditToken string
} }
// RenderComments renders a comment form partial and returns the HTML. // RenderComments renders a comment form partial and returns the HTML.
@ -73,12 +69,9 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
// Look up the author username. // Look up the author username.
if c.UserID > 0 { if c.UserID > 0 {
log.Warn("Has USERID %d", c.UserID)
if _, ok := userMap[c.UserID]; !ok { if _, ok := userMap[c.UserID]; !ok {
log.Warn("not in map")
if user, err := users.Load(c.UserID); err == nil { if user, err := users.Load(c.UserID); err == nil {
userMap[c.UserID] = user userMap[c.UserID] = user
log.Warn("is now!")
} }
} }
@ -94,8 +87,6 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
if isAdmin || (len(c.EditToken) > 0 && c.EditToken == editToken) { if isAdmin || (len(c.EditToken) > 0 && c.EditToken == editToken) {
c.Editable = true c.Editable = true
} }
fmt.Printf("%v\n", c)
} }
// Get the template snippet. // Get the template snippet.
@ -125,17 +116,17 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
Subject: subject, Subject: subject,
CSRF: csrfToken, CSRF: csrfToken,
Thread: &thread, Thread: &thread,
IsAuthenticated: isAuthenticated, NewComment: comments.Comment{
Name: name, Name: name,
Email: email, Email: email,
EditToken: editToken, IsAuthenticated: isAuthenticated,
},
} }
output := bytes.Buffer{} output := bytes.Buffer{}
err = t.Execute(&output, v) err = t.Execute(&output, v)
if err != nil { if err != nil {
log.Error(err.Error()) return template.HTML(err.Error())
return template.HTML("[error executing template in comments/comments.partial]")
} }
return template.HTML(output.String()) return template.HTML(output.String())
@ -228,8 +219,24 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
c.UserID = currentUser.ID c.UserID = currentUser.ID
} }
// Append their comment.
t.Post(c) t.Post(c)
b.NotifyComment(c)
// Are they subscribing to future comments?
if c.Subscribe && len(c.Email) > 0 {
if _, err := mail.ParseAddress(c.Email); err == nil {
m := comments.LoadMailingList()
m.Subscribe(t.ID, c.Email)
b.FlashAndRedirect(w, r, c.OriginURL,
"Comment posted, and you've been subscribed to "+
"future comments on this page.",
)
return
}
}
b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!") b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!")
return
} }
} }
@ -241,49 +248,60 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
b.RenderTemplate(w, r, "comments/index.gohtml", v) b.RenderTemplate(w, r, "comments/index.gohtml", v)
} }
// EditCommentHandler for editing comments. // SubscriptionHandler to opt out of subscriptions.
func (b *Blog) EditCommentHandler(w http.ResponseWriter, r *http.Request) { func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) {
var (
threadID = r.URL.Query().Get("t")
deleteToken = r.URL.Query().Get("d")
originURL = r.URL.Query().Get("o")
)
// Our edit token.
editToken := b.GetEditToken(w, r)
// Search for the comment.
thread, err := comments.Load(threadID)
if err != nil {
b.FlashAndRedirect(w, r, "/", "That comment thread was not found.")
return
}
comment, err := thread.FindByDeleteToken(deleteToken)
if err != nil {
b.FlashAndRedirect(w, r, "/", "That comment was not found.")
return
}
// And can we edit it?
if comment.EditToken != editToken {
b.Forbidden(w, r, "Your edit token is not valid for that comment.")
return
}
comment.ThreadID = thread.ID
comment.OriginURL = originURL
v := NewVars() v := NewVars()
v.Data["Thread"] = thread
v.Data["Comment"] = comment
v.Data["Editing"] = true
b.RenderTemplate(w, r, "comments/index.gohtml", v) // POST to unsubscribe from all threads.
if r.Method == http.MethodPost {
email := r.FormValue("email")
if email == "" {
v.Error = errors.New("email address is required to unsubscribe from comment threads")
} else if _, err := mail.ParseAddress(email); err != nil {
v.Error = errors.New("invalid email address")
} }
// DeleteCommentHandler for editing comments. m := comments.LoadMailingList()
func (b *Blog) DeleteCommentHandler(w http.ResponseWriter, r *http.Request) { m.UnsubscribeAll(email)
b.FlashAndRedirect(w, r, "/comments/subscription",
"You have been unsubscribed from all mailing lists.",
)
return
}
// GET to unsubscribe from a single thread.
thread := r.URL.Query().Get("t")
email := r.URL.Query().Get("e")
if thread != "" && email != "" {
m := comments.LoadMailingList()
m.Unsubscribe(thread, email)
b.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.")
return
}
b.RenderTemplate(w, r, "comments/subscription.gohtml", v)
}
// QuickDeleteHandler allows the admin to quickly delete spam without logging in.
func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) {
thread := r.URL.Query().Get("t")
token := r.URL.Query().Get("d")
if thread == "" || token == "" {
b.BadRequest(w, r)
return
}
t, err := comments.Load(thread)
if err != nil {
b.BadRequest(w, r, "Comment thread does not exist.")
return
}
if c, err := t.FindByDeleteToken(token); err == nil {
t.Delete(c.ID)
}
b.FlashAndRedirect(w, r, "/", "Comment deleted!")
} }
// GetEditToken gets or generates an edit token from the user's session, which // GetEditToken gets or generates an edit token from the user's session, which

View File

@ -9,11 +9,18 @@ import (
type Settings struct { type Settings struct {
Title string Title string
AdminEmail string AdminEmail string
URL string
RedisEnabled bool RedisEnabled bool
RedisHost string RedisHost string
RedisPort int RedisPort int
RedisDB int RedisDB int
RedisPrefix string RedisPrefix string
MailEnabled bool
MailSender string
MailHost string
MailPort int
MailUsername string
MailPassword string
} }
// Validate the form. // Validate the form.

121
core/mail.go Normal file
View File

@ -0,0 +1,121 @@
package core
import (
"bytes"
"crypto/tls"
"fmt"
"html/template"
"net/url"
"strings"
"github.com/kirsle/blog/core/models/comments"
"github.com/kirsle/blog/core/models/settings"
gomail "gopkg.in/gomail.v2"
)
// Email configuration.
type Email struct {
To string
Admin bool /* admin view of the email */
Subject string
UnsubscribeURL string
Data map[string]interface{}
Template string
}
// SendEmail sends an email.
func (b *Blog) SendEmail(email Email) {
s, _ := settings.Load()
if !s.Mail.Enabled || s.Mail.Host == "" || s.Mail.Port == 0 || s.Mail.Sender == "" {
log.Info("Suppressing email: not completely configured")
return
}
// Resolve the template.
tmpl, err := b.ResolvePath(email.Template)
if err != nil {
log.Error("SendEmail: %s", err.Error())
return
}
// Render the template to HTML.
var html bytes.Buffer
t := template.New(tmpl.Basename)
t, err = template.ParseFiles(tmpl.Absolute)
if err != nil {
log.Error("SendEmail: template parsing error: %s", err.Error())
}
err = t.ExecuteTemplate(&html, tmpl.Basename, email)
if err != nil {
log.Error("SendEmail: template execution error: %s", err.Error())
}
m := gomail.NewMessage()
m.SetHeader("From", s.Mail.Sender)
m.SetHeader("To", email.To)
m.SetHeader("Subject", email.Subject)
m.SetBody("text/html", html.String())
d := gomail.NewDialer(s.Mail.Host, s.Mail.Port, s.Mail.Username, s.Mail.Password)
if b.Debug {
d.TLSConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
if err := d.DialAndSend(m); err != nil {
log.Error("SendEmail: %s", err.Error())
}
}
// NotifyComment sends notification emails about comments.
func (b *Blog) NotifyComment(c *comments.Comment) {
s, _ := settings.Load()
if s.Site.URL == "" {
log.Error("Can't send comment notification because the site URL is not configured")
return
}
// Prepare the email payload.
email := Email{
Template: ".email/comment.gohtml",
Subject: "Comment Added: " + c.Subject,
Data: map[string]interface{}{
"Name": c.Name,
"Subject": c.Subject,
"Body": template.HTML(b.RenderMarkdown(c.Body)),
"URL": strings.Trim(s.Site.URL, "/") + c.OriginURL,
"QuickDelete": fmt.Sprintf("%s/comments/quick-delete?t=%s&d=%s",
strings.Trim(s.Site.URL, "/"),
url.QueryEscape(c.ThreadID),
url.QueryEscape(c.DeleteToken),
),
},
}
// Email the site admins.
config, _ := settings.Load()
if config.Site.AdminEmail != "" {
email.To = config.Site.AdminEmail
email.Admin = true
log.Info("Mail site admin '%s' about comment notification on '%s'", email.To, c.ThreadID)
b.SendEmail(email)
}
// Email the subscribers.
email.Admin = false
m := comments.LoadMailingList()
for _, to := range m.List(c.ThreadID) {
if to == c.Email {
continue // don't email yourself
}
email.To = to
email.UnsubscribeURL = fmt.Sprintf("%s/comments/subscription?t=%s&e=%s",
strings.Trim(s.Site.URL, "/"),
url.QueryEscape(c.ThreadID),
url.QueryEscape(to),
)
log.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.ThreadID)
b.SendEmail(email)
}
}

View File

@ -54,6 +54,7 @@ type Comment struct {
Trap2 string `json:"-"` Trap2 string `json:"-"`
// Even privater fields. // Even privater fields.
IsAuthenticated bool `json:"-"`
Username string `json:"-"` Username string `json:"-"`
Editable bool `json:"-"` Editable bool `json:"-"`
Editing bool `json:"-"` Editing bool `json:"-"`
@ -104,8 +105,6 @@ func (t *Thread) Post(c *Comment) error {
t.Comments = append(t.Comments, c) t.Comments = append(t.Comments, c)
DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t) DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t)
// TODO: handle subscriptions.
return nil return nil
} }
@ -143,7 +142,6 @@ func (t *Thread) Delete(id string) error {
// FindByDeleteToken finds a comment by its deletion token. // FindByDeleteToken finds a comment by its deletion token.
func (t *Thread) FindByDeleteToken(token string) (*Comment, error) { func (t *Thread) FindByDeleteToken(token string) (*Comment, error) {
for _, c := range t.Comments { for _, c := range t.Comments {
fmt.Printf("%s <> %s\n", c.DeleteToken, token)
if c.DeleteToken == token { if c.DeleteToken == token {
return c, nil return c, nil
} }

View File

@ -0,0 +1,79 @@
package comments
import "strings"
// ListDBName is the path to the singleton mailing list manager.
const ListDBName = "comments/mailing-list"
// MailingList manages subscription data for all comment threads.
type MailingList struct {
Threads map[string]Subscription
}
// Subscription is the data for a single thread's subscribers.
type Subscription struct {
Emails map[string]bool
}
// LoadMailingList loads the mailing list, or initializes it if it doesn't exist.
func LoadMailingList() *MailingList {
m := &MailingList{
Threads: map[string]Subscription{},
}
DB.Get(ListDBName, &m)
return m
}
// Subscribe to a comment thread.
func (m *MailingList) Subscribe(thread, email string) error {
email = strings.ToLower(email)
t := m.initThread(thread)
t.Emails[email] = true
return DB.Commit(ListDBName, &m)
}
// List the subscribers for a thread.
func (m *MailingList) List(thread string) []string {
t := m.initThread(thread)
result := []string{}
for email := range t.Emails {
result = append(result, email)
}
return result
}
// Unsubscribe from a comment thread. Returns true if the removal was
// successful; false indicates the email was not subscribed.
func (m *MailingList) Unsubscribe(thread, email string) bool {
email = strings.ToLower(email)
t := m.initThread(thread)
if _, ok := t.Emails[email]; ok {
delete(t.Emails, email)
DB.Commit(ListDBName, &m)
return true
}
return false
}
// UnsubscribeAll removes the email from all mailing lists.
func (m *MailingList) UnsubscribeAll(email string) bool {
var any bool
email = strings.ToLower(email)
for thread := range m.Threads {
if m.Unsubscribe(thread, email) {
any = true
}
}
return any
}
// initialize a thread structure.
func (m *MailingList) initThread(thread string) Subscription {
if _, ok := m.Threads[thread]; !ok {
m.Threads[thread] = Subscription{
Emails: map[string]bool{},
}
}
return m.Threads[thread]
}

View File

@ -19,6 +19,7 @@ type Settings struct {
Site struct { Site struct {
Title string `json:"title"` Title string `json:"title"`
AdminEmail string `json:"adminEmail"` AdminEmail string `json:"adminEmail"`
URL string `json:"url"`
} `json:"site"` } `json:"site"`
// Security-related settings. // Security-related settings.
@ -37,7 +38,14 @@ type Settings struct {
} `json:"redis"` } `json:"redis"`
// Mail settings // Mail settings
Mail struct{} `json:"mail,omitempty"` Mail struct {
Enabled bool `json:"enabled"`
Sender string `json:"sender"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
} `json:"mail,omitempty"`
} }
// Defaults returns default settings. The app initially sets this on // Defaults returns default settings. The app initially sets this on
@ -52,6 +60,8 @@ func Defaults() *Settings {
s.Redis.Host = "localhost" s.Redis.Host = "localhost"
s.Redis.Port = 6379 s.Redis.Port = 6379
s.Redis.DB = 0 s.Redis.DB = 0
s.Mail.Host = "localhost"
s.Mail.Port = 25
return s return s
} }

View File

@ -58,6 +58,7 @@ type Filepath struct {
// possible with a file extension injected. // possible with a file extension injected.
// (i.e. "/about" -> "about.html") // (i.e. "/about" -> "about.html")
URI string URI string
Basename string
Relative string // Relative path including document root (i.e. "root/about.html") Relative string // Relative path including document root (i.e. "root/about.html")
Absolute string // Absolute path on disk (i.e. "/opt/blog/root/about.html") Absolute string // Absolute path on disk (i.e. "/opt/blog/root/about.html")
} }
@ -92,6 +93,7 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
// Resolve the file path. // Resolve the file path.
relPath := filepath.Join(root, path) relPath := filepath.Join(root, path)
absPath, err := filepath.Abs(relPath) absPath, err := filepath.Abs(relPath)
basename := filepath.Base(relPath)
if err != nil { if err != nil {
log.Error("%v", err) log.Error("%v", err)
} }
@ -101,7 +103,7 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
// Found an exact hit? // Found an exact hit?
if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() { if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() {
debug("Exact filepath found: %s", absPath) debug("Exact filepath found: %s", absPath)
return Filepath{path, relPath, absPath}, nil return Filepath{path, basename, relPath, absPath}, nil
} }
// Try some supported suffixes. // Try some supported suffixes.
@ -117,7 +119,7 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
test := absPath + suffix test := absPath + suffix
if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() { if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() {
debug("Filepath found via suffix %s: %s", suffix, test) debug("Filepath found via suffix %s: %s", suffix, test)
return Filepath{path + suffix, relPath + suffix, test}, nil return Filepath{path + suffix, basename + suffix, relPath + suffix, test}, nil
} }
} }
} }

View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
<title>{{ .Subject }}</title>
</head>
<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 0; mso-line-height-rule: exactly;">
<center>
<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
<b>{{ .Subject }}</b>
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#FEFEFE">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
{{ if not .Admin }}
Hello,<br><br>
{{ end }}
{{ or .Data.Name "Anonymous" }} has left a comment on: {{ .Data.Subject }}
<br><br>
{{ .Data.Body }}
<br><br>
<hr>
To view this comment, please go to <a href="{{ .Data.URL }}" target="_blank">{{ .Data.URL }}</a>.
{{ if .Admin }}
<br><br>
Was this comment spam? <a href="{{ .Data.QuickDelete }}" target="_blank">Delete it</a>.
{{ end }}
{{ if .UnsubscribeURL }}
<br><br>
To unsubscribe from this message, visit <a href="{{ .UnsubscribeURL }}" target="_blank">{{ .UnsubscribeURL }}</a>
{{ end }}
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
This e-mail was automatically generated; do not reply to it.
</font>
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -26,6 +26,20 @@
placeholder="name@domain.com"> placeholder="name@domain.com">
</div> </div>
<div class="form-group">
<label for="admin-email">URL Root</label>
<small class="text-muted d-block">
The base absolute URL to your website. This is used to generate
emails such as comment notifications. If not provided, these
emails will not be sent.
</small>
<input type="text"
class="form-control"
name="url"
value="{{ .Site.URL }}"
placeholder="https://www.example.com/">
</div>
<h3>Redis Cache</h3> <h3>Redis Cache</h3>
<p> <p>
@ -79,6 +93,64 @@
placeholder="blog:"> placeholder="blog:">
</div> </div>
<h3>Email Settings</h3>
<div class="form-check">
<label class="form-check-label">
<input type="checkbox"
class="form-check-input"
name="mail-enabled"
value="true"
{{ if .Mail.Enabled }}checked{{ end }}>
Enable email to be sent by this site
</label>
</div>
<div class="form-group">
<label for="mail-sender">Sender Address</label>
<input type="email"
name="mail-sender"
id="mail-sender"
class="form-control"
value="{{ .Mail.Sender }}"
placeholder="no-reply@example.com">
</div>
<div class="form-group">
<label for="mail-host">SMTP Host</label>
<input type="text"
class="form-control"
name="mail-host"
id="mail-host"
value="{{ .Mail.Host }}"
placeholder="localhost">
</div>
<div class="form-group">
<label for="mail-port">SMTP Port</label>
<input type="text"
class="form-control"
name="mail-port"
id="mail-port"
value="{{ .Mail.Port }}"
placeholder="25">
</div>
<div class="form-group">
<label for="mail-username">SMTP Username</label>
<small class="text-muted">(optional)</small>
<input type="text"
class="form-control"
name="mail-username"
value="{{ .Mail.Username }}"
placeholder="">
</div>
<div class="form-group">
<label for="mail-password">SMTP Password</label>
<small class="text-muted">(optional)</small>
<input type="text"
class="form-control"
name="mail-password"
value="{{ .Mail.Password }}"
placeholder="">
</div>
<div class="form-group"> <div class="form-group">
<button type="submit" class="btn btn-primary">Save Settings</button> <button type="submit" class="btn btn-primary">Save Settings</button>
<a href="/admin" class="btn btn-secondary">Cancel</a> <a href="/admin" class="btn btn-secondary">Cancel</a>

View File

@ -2,7 +2,7 @@
{{ define "content" }} {{ define "content" }}
{{ $p := .Data.Post }} {{ $p := .Data.Post }}
{{ RenderPost $p false }} {{ RenderPost $p false 0 }}
{{ if and .LoggedIn .CurrentUser.Admin }} {{ if and .LoggedIn .CurrentUser.Admin }}
<small> <small>
@ -15,7 +15,7 @@
{{ end }} {{ end }}
{{ if $p.EnableComments }} {{ if $p.EnableComments }}
<h2 class="mt-4">Comments</h2> <h2 id="comments" class="mt-4">Comments</h2>
{{ $idStr := printf "%d" $p.ID}} {{ $idStr := printf "%d" $p.ID}}
{{ RenderComments $p.Title "post" $idStr }} {{ RenderComments $p.Title "post" $idStr }}

View File

@ -36,13 +36,19 @@
{{ end }} {{ end }}
</div> </div>
{{ if not .IndexView }}<hr>{{ end }}
{{ if $p.Tags }} {{ if $p.Tags }}
<em class="text-muted float-left pr-3">Tags:</em> <em class="text-muted float-left pr-3">Tags:</em>
<ul class="list-inline"> <ul class="list-inline">
{{ range $p.Tags }} {{ range $p.Tags }}
<li class="list-inline-item"><a href="/tagged/{{ . }}">{{ . }}</a></li> <li class="list-inline-item text-muted"><em><a href="/tagged/{{ . }}">{{ . }}</a></em></li>
{{ end }} {{ end }}
</ul> </ul>
{{ end }} {{ end }}
{{ if .IndexView }}
<em class="text-muted">
<a href="/{{ $p.Fragment }}#comments">{{ .NumComments }} comment{{ if ne .NumComments 1 }}s{{ end }}</a>
|
<a href="/{{ $p.Fragment }}">Permalink</a>
</em>
{{ end }}

View File

@ -15,7 +15,7 @@
{{ range .Data.View }} {{ range .Data.View }}
{{ $p := .Post }} {{ $p := .Post }}
{{ RenderPost $p true }} {{ RenderPost $p true .NumComments }}
{{ if and $.LoggedIn $.CurrentUser.Admin }} {{ if and $.LoggedIn $.CurrentUser.Admin }}
<div class="mb-4"> <div class="mb-4">

View File

@ -36,6 +36,10 @@ label.form-check-label {
button { button {
cursor: pointer; cursor: pointer;
} }
.text-muted a {
color: #868e96 !important;
text-decoration: underline;
}
/* /*
* Top nav * Top nav

View File

@ -6,8 +6,8 @@
There is 1 comment on this page. There is 1 comment on this page.
{{- else -}} {{- else -}}
There are {{ len $t.Comments }} comments on this page. There are {{ len $t.Comments }} comments on this page.
{{- end -}} {{- end }}
<a href="#add-comment">Add your comment.</a> <a href="#add-comment">Add yours.</a>
</p> </p>
{{ range $t.Comments }} {{ range $t.Comments }}
@ -22,69 +22,7 @@
<input type="hidden" name="subject" value="{{ .Subject }}"> <input type="hidden" name="subject" value="{{ .Subject }}">
<input type="hidden" name="origin" value="{{ .OriginURL }}"> <input type="hidden" name="origin" value="{{ .OriginURL }}">
{{ if not .IsAuthenticated }} {{ template "comment-form" .NewComment }}
<div class="form-group row">
<label for="name" class="col-2 col-form-label">Your name:</label>
<div class="col-10">
<input type="text"
id="name"
name="name"
class="form-control"
value="{{ .Name }}"
placeholder="Anonymous">
</div>
</div>
<div class="form-group row">
<label for="email" class="col-2 col-form-label">Your email:</label>
<div class="col-10">
<input type="email"
id="email"
name="email"
class="form-control"
aria-describedby="emailHelp"
value="{{ .Email }}"
placeholder="(optional)">
<small id="emailHelp" class="form-text text-muted">
Used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>
and optional thread subscription. <a href="/privacy">Privacy policy.</a>
</small>
<label class="form-check-label pl-0">
<input type="checkbox" name="subscribe" value="true">
<small>Notify me of future comments on this page.</small>
</label>
</div>
</div>
{{ end }}
<div class="form-group">
<label for="body">Message:</label>
<textarea
name="body"
id="body"
cols="40" rows="10"
aria-describedby="bodyHelp"
class="form-control"></textarea>
<small id="bodyHelp" class="form-text text-muted">
You may format your message using
<a href="https://daringfireball.net/projects/markdown/syntax">Markdown</a>
syntax.
</small>
</div>
<div class="form-group" style="display: none">
<div class="card">
<div class="card-header">Sanity Check</div>
<div class="card-body">
If you happen to be able to see these fields, do not change
their values.
<input type="text" name="url" value="http://" class="form-control" placeholder="Website">
<textarea name="comment" cols="80" rows="10" class="form-control" placeholder="Comment"></textarea>
</div>
</div>
</div>
<button type="submit" <button type="submit"
name="submit" name="submit"

View File

@ -51,3 +51,71 @@
</div> </div>
</div> </div>
{{ end }} {{ end }}
{{ define "comment-form" }}
{{ if not .IsAuthenticated }}
<div class="form-group row">
<label for="name" class="col-2 col-form-label">Your name:</label>
<div class="col-10">
<input type="text"
id="name"
name="name"
class="form-control"
value="{{ .Name }}"
placeholder="Anonymous">
</div>
</div>
<div class="form-group row">
<label for="email" class="col-2 col-form-label">Your email:</label>
<div class="col-10">
<input type="email"
id="email"
name="email"
class="form-control"
aria-describedby="emailHelp"
value="{{ .Email }}"
placeholder="(optional)">
<small id="emailHelp" class="form-text text-muted">
Used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>
and optional thread subscription. <a href="/comments/subscription" target="_blank">Privacy policy.</a>
</small>
<label class="form-check-label pl-0">
<input type="checkbox"{{ if .Subscribe }} checked{{ end }}
name="subscribe"
value="true">
<small>Notify me of future comments on this page.</small>
</label>
</div>
</div>
{{ end }}
<div class="form-group">
<label for="body">Message:</label>
<textarea
name="body"
id="body"
cols="40" rows="10"
aria-describedby="bodyHelp"
class="form-control">{{ .Body }}</textarea>
<small id="bodyHelp" class="form-text text-muted">
You may format your message using
<a href="https://daringfireball.net/projects/markdown/syntax">Markdown</a>
syntax.
</small>
</div>
<div class="form-group" style="display: none">
<div class="card">
<div class="card-header">Sanity Check</div>
<div class="card-body">
If you happen to be able to see these fields, do not change
their values.
<input type="text" name="url" value="http://" class="form-control" placeholder="Website">
<textarea name="comment" cols="80" rows="10" class="form-control" placeholder="Comment"></textarea>
</div>
</div>
</div>
{{ end }}

View File

@ -39,75 +39,7 @@
</button> </button>
<a href="{{ .OriginURL }}" class="btn btn-primary">Cancel</a> <a href="{{ .OriginURL }}" class="btn btn-primary">Cancel</a>
{{ else }} {{ else }}
{{ if not $.CurrentUser.IsAuthenticated }} {{ template "comment-form" . }}
<div class="form-group row">
<label for="name" class="col-2 col-form-label">Your name:</label>
<div class="col-10">
{{ if and $.CurrentUser.IsAuthenticated }}
{{ $.CurrentUser.Name }}
{{ else }}
<input type="text"
id="name"
name="name"
class="form-control"
value="{{ .Name }}"
placeholder="Anonymous">
{{ end }}
</div>
</div>
<div class="form-group row">
<label for="email" class="col-2 col-form-label">Your email:</label>
<div class="col-10">
<input type="email"
id="email"
name="email"
class="form-control"
aria-describedby="emailHelp"
value="{{ .Email }}"
placeholder="(optional)">
<small id="emailHelp" class="form-text text-muted">
Used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>
and optional thread subscription. <a href="/privacy">Privacy policy.</a>
</small>
<label class="form-check-label pl-0">
<input type="checkbox"{{ if .Subscribe }} checked{{ end }}
name="subscribe"
value="true">
<small>Notify me of future comments on this page.</small>
</label>
</div>
</div>
{{ end }}
<div class="form-group">
<label for="body">Message:</label>
<textarea
name="body"
id="body"
cols="40" rows="10"
aria-describedby="bodyHelp"
class="form-control">{{ .Body }}</textarea>
<small id="bodyHelp" class="form-text text-muted">
You may format your message using
<a href="https://daringfireball.net/projects/markdown/syntax">Markdown</a>
syntax.
</small>
</div>
<div class="form-group" style="display: none">
<div class="card">
<div class="card-header">Sanity Check</div>
<div class="card-body">
If you happen to be able to see these fields, do not change
their values.
<input type="text" name="url" value="http://" class="form-control" placeholder="Website">
<textarea name="comment" cols="80" rows="10" class="form-control" placeholder="Comment"></textarea>
</div>
</div>
</div>
<button type="submit" name="submit" value="preview" class="btn btn-primary"> <button type="submit" name="submit" value="preview" class="btn btn-primary">
Refresh Preview Refresh Preview

View File

@ -0,0 +1,50 @@
{{ define "title" }}Manage Comment Subscriptions{{ end }}
{{ define "content" }}
<h1>Comment Subscriptions</h1>
<p>
When leaving comments on this website, you may <em>optionally</em>
subscribe to get email notifications when new comments are added to
the same thread. This way you can get notified when your question
has been answered, for example.
</p>
<h2>Privacy Policy</h2>
<p>
This web blog is open source software, so the following is true as far as
the web blog software itself is concerned. Please check with the site
administrator for their personal privacy policy.
</p>
<p>
Your email address is used for the following purposes by this web blog:
</p>
<ul>
<li>Showing your <a href="https://www.gravatar.com/" target="_blank">Gravatar</a> next to your comment.</li>
<li>With your permission: sending you notifications about future comments on the page.</li>
</ul>
<h2>Unsubscribe</h2>
<p>
To unsubscribe from individual comment threads, use the "Unsubscribe" links
in the emails. Or, to unsubcribe from <strong>all</strong> comment threads,
enter your email address below.
</p>
<form class="form-inline" action="/comments/subscription" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<label class="sr-only" for="email">Email address</label>
<input type="email"
name="email"
id="email"
class="form-control mr-2"
placeholder="name@domain.com">
<button type="submit" class="btn btn-primary">Unsubscribe</button>
</form>
{{ end }}