Add support for subscribing to comment threads
This commit is contained in:
parent
725437d06f
commit
527e995c1c
|
@ -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
|
||||
|
|
13
core/blog.go
13
core/blog.go
|
@ -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"
|
||||
|
@ -21,6 +23,7 @@ type PostMeta struct {
|
|||
Post *posts.Post
|
||||
Rendered template.HTML
|
||||
Author *users.User
|
||||
NumComments int
|
||||
IndexView bool
|
||||
Snipped bool
|
||||
}
|
||||
|
@ -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,
|
||||
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 {
|
||||
|
@ -332,6 +342,7 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
|
|||
Author: author,
|
||||
IndexView: indexView,
|
||||
Snipped: snipped,
|
||||
NumComments: numComments,
|
||||
}
|
||||
output := bytes.Buffer{}
|
||||
err = t.Execute(&output, meta)
|
||||
|
|
130
core/comments.go
130
core/comments.go
|
@ -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
|
||||
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
|
||||
|
||||
// Cached name and email of the user.
|
||||
Name string
|
||||
Email string
|
||||
EditToken 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.
|
||||
|
@ -125,17 +116,17 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
|
|||
Subject: subject,
|
||||
CSRF: csrfToken,
|
||||
Thread: &thread,
|
||||
IsAuthenticated: isAuthenticated,
|
||||
NewComment: comments.Comment{
|
||||
Name: name,
|
||||
Email: email,
|
||||
EditToken: editToken,
|
||||
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")
|
||||
}
|
||||
|
||||
// DeleteCommentHandler for editing comments.
|
||||
func (b *Blog) DeleteCommentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
@ -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
121
core/mail.go
Normal 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)
|
||||
}
|
||||
}
|
|
@ -54,6 +54,7 @@ type Comment struct {
|
|||
Trap2 string `json:"-"`
|
||||
|
||||
// Even privater fields.
|
||||
IsAuthenticated bool `json:"-"`
|
||||
Username string `json:"-"`
|
||||
Editable bool `json:"-"`
|
||||
Editing bool `json:"-"`
|
||||
|
@ -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
|
||||
}
|
||||
|
|
79
core/models/comments/subscribers.go
Normal file
79
core/models/comments/subscribers.go
Normal 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]
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
61
root/.email/comment.gohtml
Normal file
61
root/.email/comment.gohtml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -36,6 +36,10 @@ label.form-check-label {
|
|||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.text-muted a {
|
||||
color: #868e96 !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/*
|
||||
* Top nav
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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
|
||||
|
|
50
root/comments/subscription.gohtml
Normal file
50
root/comments/subscription.gohtml
Normal 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 }}
|
Loading…
Reference in New Issue
Block a user