Add support for subscribing to comment threads

pull/4/head
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 {
redisPort, _ := strconv.Atoi(r.FormValue("redis-port"))
redisDB, _ := strconv.Atoi(r.FormValue("redis-db"))
mailPort, _ := strconv.Atoi(r.FormValue("mail-port"))
form := &forms.Settings{
Title: r.FormValue("title"),
AdminEmail: r.FormValue("admin-email"),
URL: r.FormValue("url"),
RedisEnabled: r.FormValue("redis-enabled") == "true",
RedisHost: r.FormValue("redis-host"),
RedisPort: redisPort,
RedisDB: redisDB,
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
// any validation errors.
settings.Site.Title = form.Title
settings.Site.AdminEmail = form.AdminEmail
settings.Site.URL = form.URL
settings.Redis.Enabled = form.RedisEnabled
settings.Redis.Host = form.RedisHost
settings.Redis.Port = form.RedisPort
settings.Redis.DB = form.RedisDB
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()
if err != nil {
v.Error = err

View File

@ -3,6 +3,7 @@ package core
import (
"bytes"
"errors"
"fmt"
"html/template"
"net/http"
"sort"
@ -11,6 +12,7 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/kirsle/blog/core/models/comments"
"github.com/kirsle/blog/core/models/posts"
"github.com/kirsle/blog/core/models/users"
"github.com/urfave/negroni"
@ -18,11 +20,12 @@ import (
// PostMeta associates a Post with injected metadata.
type PostMeta struct {
Post *posts.Post
Rendered template.HTML
Author *users.User
IndexView bool
Snipped bool
Post *posts.Post
Rendered template.HTML
Author *users.User
NumComments int
IndexView bool
Snipped bool
}
// Archive holds data for a piece of the blog archive.
@ -201,10 +204,17 @@ func (b *Blog) PartialIndex(w http.ResponseWriter, r *http.Request,
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{
Post: post,
Rendered: rendered,
Author: author,
Post: post,
Rendered: rendered,
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.
// If indexView is true, the blog headers will be hyperlinked to the dedicated
// 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.
author, err := users.LoadReadonly(p.AuthorID)
if err != nil {
@ -327,11 +337,12 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
}
meta := PostMeta{
Post: p,
Rendered: rendered,
Author: author,
IndexView: indexView,
Snipped: snipped,
Post: p,
Rendered: rendered,
Author: author,
IndexView: indexView,
Snipped: snipped,
NumComments: numComments,
}
output := bytes.Buffer{}
err = t.Execute(&output, meta)

View File

@ -2,9 +2,10 @@ package core
import (
"bytes"
"fmt"
"errors"
"html/template"
"net/http"
"net/mail"
"strings"
"github.com/google/uuid"
@ -17,24 +18,19 @@ import (
// CommentRoutes attaches the comment routes to the app.
func (b *Blog) CommentRoutes(r *mux.Router) {
r.HandleFunc("/comments", b.CommentHandler)
r.HandleFunc("/comments/edit", b.EditCommentHandler)
r.HandleFunc("/comments/delete", b.DeleteCommentHandler)
r.HandleFunc("/comments/subscription", b.SubscriptionHandler)
r.HandleFunc("/comments/quick-delete", b.QuickDeleteHandler)
}
// CommentMeta is the template variables for comment threads.
type CommentMeta struct {
IsAuthenticated bool
ID string
OriginURL string // URL where original comment thread appeared
Subject string // email subject
Thread *comments.Thread
Authors map[int]*users.User
CSRF string
// Cached name and email of the user.
Name string
Email string
EditToken string
NewComment comments.Comment
ID string
OriginURL string // URL where original comment thread appeared
Subject string // email subject
Thread *comments.Thread
Authors map[int]*users.User
CSRF string
}
// 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.
if c.UserID > 0 {
log.Warn("Has USERID %d", c.UserID)
if _, ok := userMap[c.UserID]; !ok {
log.Warn("not in map")
if user, err := users.Load(c.UserID); err == nil {
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) {
c.Editable = true
}
fmt.Printf("%v\n", c)
}
// Get the template snippet.
@ -120,22 +111,22 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
}
v := CommentMeta{
ID: thread.ID,
OriginURL: url,
Subject: subject,
CSRF: csrfToken,
Thread: &thread,
IsAuthenticated: isAuthenticated,
Name: name,
Email: email,
EditToken: editToken,
ID: thread.ID,
OriginURL: url,
Subject: subject,
CSRF: csrfToken,
Thread: &thread,
NewComment: comments.Comment{
Name: name,
Email: email,
IsAuthenticated: isAuthenticated,
},
}
output := bytes.Buffer{}
err = t.Execute(&output, v)
if err != nil {
log.Error(err.Error())
return template.HTML("[error executing template in comments/comments.partial]")
return template.HTML(err.Error())
}
return template.HTML(output.String())
@ -228,8 +219,24 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
c.UserID = currentUser.ID
}
// Append their comment.
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!")
return
}
}
@ -241,49 +248,60 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
b.RenderTemplate(w, r, "comments/index.gohtml", v)
}
// EditCommentHandler for editing comments.
func (b *Blog) EditCommentHandler(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
// SubscriptionHandler to opt out of subscriptions.
func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) {
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")
}
m := comments.LoadMailingList()
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)
}
// DeleteCommentHandler for editing comments.
func (b *Blog) DeleteCommentHandler(w http.ResponseWriter, r *http.Request) {
// 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

View File

@ -9,11 +9,18 @@ import (
type Settings struct {
Title string
AdminEmail string
URL string
RedisEnabled bool
RedisHost string
RedisPort int
RedisDB int
RedisPrefix string
MailEnabled bool
MailSender string
MailHost string
MailPort int
MailUsername string
MailPassword string
}
// 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,9 +54,10 @@ type Comment struct {
Trap2 string `json:"-"`
// Even privater fields.
Username string `json:"-"`
Editable bool `json:"-"`
Editing bool `json:"-"`
IsAuthenticated bool `json:"-"`
Username string `json:"-"`
Editable bool `json:"-"`
Editing bool `json:"-"`
}
// New initializes a new comment thread.
@ -104,8 +105,6 @@ func (t *Thread) Post(c *Comment) error {
t.Comments = append(t.Comments, c)
DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t)
// TODO: handle subscriptions.
return nil
}
@ -143,7 +142,6 @@ func (t *Thread) Delete(id string) error {
// FindByDeleteToken finds a comment by its deletion token.
func (t *Thread) FindByDeleteToken(token string) (*Comment, error) {
for _, c := range t.Comments {
fmt.Printf("%s <> %s\n", c.DeleteToken, token)
if c.DeleteToken == token {
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 {
Title string `json:"title"`
AdminEmail string `json:"adminEmail"`
URL string `json:"url"`
} `json:"site"`
// Security-related settings.
@ -37,7 +38,14 @@ type Settings struct {
} `json:"redis"`
// 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
@ -52,6 +60,8 @@ func Defaults() *Settings {
s.Redis.Host = "localhost"
s.Redis.Port = 6379
s.Redis.DB = 0
s.Mail.Host = "localhost"
s.Mail.Port = 25
return s
}

View File

@ -58,6 +58,7 @@ type Filepath struct {
// possible with a file extension injected.
// (i.e. "/about" -> "about.html")
URI string
Basename string
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")
}
@ -92,6 +93,7 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
// Resolve the file path.
relPath := filepath.Join(root, path)
absPath, err := filepath.Abs(relPath)
basename := filepath.Base(relPath)
if err != nil {
log.Error("%v", err)
}
@ -101,7 +103,7 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
// Found an exact hit?
if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() {
debug("Exact filepath found: %s", absPath)
return Filepath{path, relPath, absPath}, nil
return Filepath{path, basename, relPath, absPath}, nil
}
// Try some supported suffixes.
@ -117,7 +119,7 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
test := absPath + suffix
if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() {
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">
</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>
<p>
@ -79,6 +93,64 @@
placeholder="blog:">
</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">
<button type="submit" class="btn btn-primary">Save Settings</button>
<a href="/admin" class="btn btn-secondary">Cancel</a>

View File

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

View File

@ -36,13 +36,19 @@
{{ end }}
</div>
{{ if not .IndexView }}<hr>{{ end }}
{{ if $p.Tags }}
<em class="text-muted float-left pr-3">Tags:</em>
<ul class="list-inline">
{{ 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 }}
</ul>
{{ 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 }}
{{ $p := .Post }}
{{ RenderPost $p true }}
{{ RenderPost $p true .NumComments }}
{{ if and $.LoggedIn $.CurrentUser.Admin }}
<div class="mb-4">

View File

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

View File

@ -6,8 +6,8 @@
There is 1 comment on this page.
{{- else -}}
There are {{ len $t.Comments }} comments on this page.
{{- end -}}
<a href="#add-comment">Add your comment.</a>
{{- end }}
<a href="#add-comment">Add yours.</a>
</p>
{{ range $t.Comments }}
@ -22,69 +22,7 @@
<input type="hidden" name="subject" value="{{ .Subject }}">
<input type="hidden" name="origin" value="{{ .OriginURL }}">
{{ 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="/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>
{{ template "comment-form" .NewComment }}
<button type="submit"
name="submit"

View File

@ -51,3 +51,71 @@
</div>
</div>
{{ 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

@ -2,7 +2,7 @@
{{ define "content" }}
{{ with .Data.Comment }}
<form action="/comments" method="POST">
<form action="/comments" method="POST">
<input type="hidden" name="_csrf" value="{{ $.CSRF }}">
<input type="hidden" name="thread" value="{{ .ThreadID }}">
<input type="hidden" name="subject" value="{{ .Subject }}">
@ -39,75 +39,7 @@
</button>
<a href="{{ .OriginURL }}" class="btn btn-primary">Cancel</a>
{{ else }}
{{ if not $.CurrentUser.IsAuthenticated }}
<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>
{{ template "comment-form" . }}
<button type="submit" name="submit" value="preview" class="btn btn-primary">
Refresh Preview
@ -116,7 +48,7 @@
Post Comment
</button>
{{ end }}
</form>
</form>
{{ end }}
{{ end }}

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