Comment system: add, edit, delete for guests and Admins
This commit is contained in:
parent
ab5430df26
commit
725437d06f
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/kirsle/blog/core/jsondb"
|
||||
"github.com/kirsle/blog/core/models/comments"
|
||||
"github.com/kirsle/blog/core/models/posts"
|
||||
"github.com/kirsle/blog/core/models/settings"
|
||||
"github.com/kirsle/blog/core/models/users"
|
||||
|
@ -25,9 +26,6 @@ type Blog struct {
|
|||
|
||||
DB *jsondb.DB
|
||||
|
||||
// Helper singletone
|
||||
Posts *PostHelper
|
||||
|
||||
// Web app objects.
|
||||
n *negroni.Negroni // Negroni middleware manager
|
||||
r *mux.Router // Router
|
||||
|
@ -41,7 +39,6 @@ func New(documentRoot, userRoot string) *Blog {
|
|||
UserRoot: userRoot,
|
||||
DB: jsondb.New(filepath.Join(userRoot, ".private")),
|
||||
}
|
||||
blog.Posts = InitPostHelper(blog)
|
||||
|
||||
// Load the site config, or start with defaults if not found.
|
||||
settings.DB = blog.DB
|
||||
|
@ -57,6 +54,7 @@ func New(documentRoot, userRoot string) *Blog {
|
|||
// Initialize the rest of the models.
|
||||
posts.DB = blog.DB
|
||||
users.DB = blog.DB
|
||||
comments.DB = blog.DB
|
||||
|
||||
// Initialize the router.
|
||||
r := mux.NewRouter()
|
||||
|
@ -65,6 +63,7 @@ func New(documentRoot, userRoot string) *Blog {
|
|||
r.HandleFunc("/logout", blog.LogoutHandler)
|
||||
blog.AdminRoutes(r)
|
||||
blog.BlogRoutes(r)
|
||||
blog.CommentRoutes(r)
|
||||
|
||||
// GitHub Flavored Markdown CSS.
|
||||
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))
|
||||
|
|
10
core/blog.go
10
core/blog.go
|
@ -91,7 +91,7 @@ func (b *Blog) PrivatePosts(w http.ResponseWriter, r *http.Request) {
|
|||
// PartialIndex handles common logic for blog index views.
|
||||
func (b *Blog) PartialIndex(w http.ResponseWriter, r *http.Request,
|
||||
tag, privacy string) {
|
||||
v := NewVars(map[interface{}]interface{}{})
|
||||
v := NewVars()
|
||||
|
||||
// Get the blog index.
|
||||
idx, _ := posts.GetIndex()
|
||||
|
@ -317,13 +317,13 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
|
|||
filepath, err := b.ResolvePath("blog/entry.partial")
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
return "[error: missing blog/entry.partial]"
|
||||
return template.HTML("[error: missing blog/entry.partial]")
|
||||
}
|
||||
t := template.New("entry.partial.gohtml")
|
||||
t, err = t.ParseFiles(filepath.Absolute)
|
||||
if err != nil {
|
||||
log.Error("Failed to parse entry.partial: %s", err.Error())
|
||||
return "[error parsing template in blog/entry.partial]"
|
||||
return template.HTML("[error parsing template in blog/entry.partial]")
|
||||
}
|
||||
|
||||
meta := PostMeta{
|
||||
|
@ -337,7 +337,7 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
|
|||
err = t.Execute(&output, meta)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
return "[error executing template in blog/entry.partial]"
|
||||
return template.HTML("[error executing template in blog/entry.partial]")
|
||||
}
|
||||
|
||||
return template.HTML(output.String())
|
||||
|
@ -372,7 +372,7 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) {
|
|||
switch r.FormValue("submit") {
|
||||
case "preview":
|
||||
if post.ContentType == string(MARKDOWN) {
|
||||
v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body))
|
||||
v.Data["preview"] = template.HTML(b.RenderTrustedMarkdown(post.Body))
|
||||
} else {
|
||||
v.Data["preview"] = template.HTML(post.Body)
|
||||
}
|
||||
|
|
301
core/comments.go
Normal file
301
core/comments.go
Normal file
|
@ -0,0 +1,301 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/kirsle/blog/core/models/comments"
|
||||
"github.com/kirsle/blog/core/models/users"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// RenderComments renders a comment form partial and returns the HTML.
|
||||
func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject string, ids ...string) template.HTML {
|
||||
id := strings.Join(ids, "-")
|
||||
|
||||
// Load their cached name and email if they posted a comment before.
|
||||
name, _ := session.Values["c.name"].(string)
|
||||
email, _ := session.Values["c.email"].(string)
|
||||
editToken, _ := session.Values["c.token"].(string)
|
||||
|
||||
// Check if the user is a logged-in admin, to make all comments editable.
|
||||
var isAdmin bool
|
||||
var isAuthenticated bool
|
||||
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
||||
isAuthenticated = true
|
||||
if userID, ok := session.Values["user-id"].(int); ok {
|
||||
if user, err := users.Load(userID); err == nil {
|
||||
isAdmin = user.Admin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread, err := comments.Load(id)
|
||||
if err != nil {
|
||||
thread = comments.New(id)
|
||||
}
|
||||
|
||||
// Render all the comments in the thread.
|
||||
userMap := map[int]*users.User{}
|
||||
for _, c := range thread.Comments {
|
||||
c.HTML = template.HTML(b.RenderMarkdown(c.Body))
|
||||
c.ThreadID = thread.ID
|
||||
c.OriginURL = url
|
||||
c.CSRF = csrfToken
|
||||
|
||||
// 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!")
|
||||
}
|
||||
}
|
||||
|
||||
if user, ok := userMap[c.UserID]; ok {
|
||||
c.Name = user.Name
|
||||
c.Username = user.Username
|
||||
c.Email = user.Email
|
||||
c.LoadAvatar()
|
||||
}
|
||||
}
|
||||
|
||||
// Is it editable?
|
||||
if isAdmin || (len(c.EditToken) > 0 && c.EditToken == editToken) {
|
||||
c.Editable = true
|
||||
}
|
||||
|
||||
fmt.Printf("%v\n", c)
|
||||
}
|
||||
|
||||
// Get the template snippet.
|
||||
filepath, err := b.ResolvePath("comments/comments.partial")
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
return template.HTML("[error: missing comments/comments.partial]")
|
||||
}
|
||||
|
||||
// And the comment view partial.
|
||||
entryPartial, err := b.ResolvePath("comments/entry.partial")
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
return template.HTML("[error: missing comments/entry.partial]")
|
||||
}
|
||||
|
||||
t := template.New("comments.partial.gohtml")
|
||||
t, err = t.ParseFiles(entryPartial.Absolute, filepath.Absolute)
|
||||
if err != nil {
|
||||
log.Error("Failed to parse comments.partial: %s", err.Error())
|
||||
return template.HTML("[error parsing template in comments/comments.partial]")
|
||||
}
|
||||
|
||||
v := CommentMeta{
|
||||
ID: thread.ID,
|
||||
OriginURL: url,
|
||||
Subject: subject,
|
||||
CSRF: csrfToken,
|
||||
Thread: &thread,
|
||||
IsAuthenticated: isAuthenticated,
|
||||
Name: name,
|
||||
Email: email,
|
||||
EditToken: editToken,
|
||||
}
|
||||
|
||||
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(output.String())
|
||||
}
|
||||
|
||||
// CommentHandler handles the /comments URI for previewing and posting.
|
||||
func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
b.BadRequest(w, r, "That method is not allowed.")
|
||||
return
|
||||
}
|
||||
v := NewVars()
|
||||
currentUser, _ := b.CurrentUser(r)
|
||||
editToken := b.GetEditToken(w, r)
|
||||
submit := r.FormValue("submit")
|
||||
|
||||
// Load the comment data from the form.
|
||||
c := &comments.Comment{}
|
||||
c.ParseForm(r)
|
||||
if c.ThreadID == "" {
|
||||
b.FlashAndRedirect(w, r, "/", "No thread ID found in the comment form.")
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the thread.
|
||||
t, err := comments.Load(c.ThreadID)
|
||||
if err != nil {
|
||||
t = comments.New(c.ThreadID)
|
||||
}
|
||||
|
||||
// Origin URL to redirect them to at the end.
|
||||
origin := "/"
|
||||
if c.OriginURL != "" {
|
||||
origin = c.OriginURL
|
||||
}
|
||||
|
||||
// Are we editing a post?
|
||||
if r.FormValue("editing") == "true" {
|
||||
id := r.FormValue("id")
|
||||
c, err = t.Find(id)
|
||||
if err != nil {
|
||||
b.FlashAndRedirect(w, r, "/", "That comment was not found.")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify they have the matching edit token. Admin users are allowed.
|
||||
if c.EditToken != editToken && !currentUser.Admin {
|
||||
b.FlashAndRedirect(w, r, origin, "You don't have permission to edit that comment.")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the extra form data into the comment struct.
|
||||
c.ParseForm(r)
|
||||
}
|
||||
|
||||
// Are we deleting said post?
|
||||
if submit == "confirm-delete" {
|
||||
t.Delete(c.ID)
|
||||
b.FlashAndRedirect(w, r, origin, "Comment deleted!")
|
||||
return
|
||||
}
|
||||
|
||||
// Cache their name and email in their session.
|
||||
session := b.Session(r)
|
||||
session.Values["c.name"] = c.Name
|
||||
session.Values["c.email"] = c.Email
|
||||
session.Save(r, w)
|
||||
|
||||
// Previewing, deleting, or posting?
|
||||
switch submit {
|
||||
case "preview", "delete":
|
||||
if !c.Editing && currentUser.IsAuthenticated {
|
||||
c.Name = currentUser.Name
|
||||
c.Email = currentUser.Email
|
||||
c.LoadAvatar()
|
||||
}
|
||||
c.HTML = template.HTML(b.RenderMarkdown(c.Body))
|
||||
case "post":
|
||||
if err := c.Validate(); err != nil {
|
||||
v.Error = err
|
||||
} else {
|
||||
// Store our edit token, if we don't have one. For example, admins
|
||||
// can edit others' comments but should not replace their edit token.
|
||||
if c.EditToken == "" {
|
||||
c.EditToken = editToken
|
||||
}
|
||||
|
||||
// If we're logged in, tag our user ID with this post.
|
||||
if !c.Editing && c.UserID == 0 && currentUser.IsAuthenticated {
|
||||
c.UserID = currentUser.ID
|
||||
}
|
||||
|
||||
t.Post(c)
|
||||
b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!")
|
||||
}
|
||||
}
|
||||
|
||||
v.Data["Thread"] = t
|
||||
v.Data["Comment"] = c
|
||||
v.Data["Editing"] = c.Editing
|
||||
v.Data["Deleting"] = submit == "delete"
|
||||
|
||||
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
|
||||
|
||||
v := NewVars()
|
||||
v.Data["Thread"] = thread
|
||||
v.Data["Comment"] = comment
|
||||
v.Data["Editing"] = true
|
||||
|
||||
b.RenderTemplate(w, r, "comments/index.gohtml", v)
|
||||
}
|
||||
|
||||
// DeleteCommentHandler for editing comments.
|
||||
func (b *Blog) DeleteCommentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
// GetEditToken gets or generates an edit token from the user's session, which
|
||||
// allows a user to edit their comment for a short while after they post it.
|
||||
func (b *Blog) GetEditToken(w http.ResponseWriter, r *http.Request) string {
|
||||
session := b.Session(r)
|
||||
if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 {
|
||||
return token
|
||||
}
|
||||
|
||||
token := uuid.New().String()
|
||||
session.Values["c.token"] = token
|
||||
session.Save(r, w)
|
||||
return token
|
||||
}
|
|
@ -23,6 +23,5 @@ func (b *Blog) RenderMarkdown(input string) string {
|
|||
// Markdown pages, not for user-submitted comments or things.
|
||||
func (b *Blog) RenderTrustedMarkdown(input string) string {
|
||||
html := github_flavored_markdown.Markdown([]byte(input))
|
||||
log.Info("%s", html)
|
||||
return string(html)
|
||||
}
|
||||
|
|
|
@ -73,6 +73,7 @@ func (b *Blog) CurrentUser(r *http.Request) (*users.User, error) {
|
|||
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
|
||||
id := session.Values["user-id"].(int)
|
||||
u, err := users.LoadReadonly(id)
|
||||
u.IsAuthenticated = true
|
||||
return u, err
|
||||
}
|
||||
|
||||
|
|
228
core/models/comments/comments.go
Normal file
228
core/models/comments/comments.go
Normal file
|
@ -0,0 +1,228 @@
|
|||
package comments
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/kirsle/blog/core/jsondb"
|
||||
"github.com/kirsle/golog"
|
||||
)
|
||||
|
||||
// DB is a reference to the parent app's JsonDB object.
|
||||
var DB *jsondb.DB
|
||||
|
||||
var log *golog.Logger
|
||||
|
||||
func init() {
|
||||
log = golog.GetLogger("blog")
|
||||
}
|
||||
|
||||
// Thread contains a thread of comments, for a blog post or otherwise.
|
||||
type Thread struct {
|
||||
ID string `json:"id"`
|
||||
Comments []*Comment `json:"comments"`
|
||||
}
|
||||
|
||||
// Comment contains the data for a single comment in a thread.
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
UserID int `json:"userId,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Avatar string `json:"avatar"`
|
||||
Body string `json:"body"`
|
||||
EditToken string `json:"editToken"`
|
||||
DeleteToken string `json:"deleteToken"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
|
||||
// Private form use only.
|
||||
CSRF string `json:"-"`
|
||||
Subscribe bool `json:"-"`
|
||||
ThreadID string `json:"-"`
|
||||
OriginURL string `json:"-"`
|
||||
Subject string `json:"-"`
|
||||
HTML template.HTML `json:"-"`
|
||||
Trap1 string `json:"-"`
|
||||
Trap2 string `json:"-"`
|
||||
|
||||
// Even privater fields.
|
||||
Username string `json:"-"`
|
||||
Editable bool `json:"-"`
|
||||
Editing bool `json:"-"`
|
||||
}
|
||||
|
||||
// New initializes a new comment thread.
|
||||
func New(id string) Thread {
|
||||
return Thread{
|
||||
ID: id,
|
||||
Comments: []*Comment{},
|
||||
}
|
||||
}
|
||||
|
||||
// Load a comment thread.
|
||||
func Load(id string) (Thread, error) {
|
||||
t := Thread{}
|
||||
err := DB.Get(fmt.Sprintf("comments/threads/%s", id), &t)
|
||||
return t, err
|
||||
}
|
||||
|
||||
// Post a comment to a thread.
|
||||
func (t *Thread) Post(c *Comment) error {
|
||||
// If it has an ID, update an existing comment.
|
||||
if len(c.ID) > 0 {
|
||||
idx := -1
|
||||
for i, comment := range t.Comments {
|
||||
if comment.ID == c.ID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the comment by index.
|
||||
if idx >= 0 && idx < len(t.Comments) {
|
||||
t.Comments[idx] = c
|
||||
DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Assign an ID.
|
||||
if c.ID == "" {
|
||||
c.ID = uuid.New().String()
|
||||
}
|
||||
if c.DeleteToken == "" {
|
||||
c.DeleteToken = uuid.New().String()
|
||||
}
|
||||
|
||||
t.Comments = append(t.Comments, c)
|
||||
DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t)
|
||||
|
||||
// TODO: handle subscriptions.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find a comment by its ID.
|
||||
func (t *Thread) Find(id string) (*Comment, error) {
|
||||
for _, c := range t.Comments {
|
||||
if c.ID == id {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("comment not found")
|
||||
}
|
||||
|
||||
// Delete a comment by its ID.
|
||||
func (t *Thread) Delete(id string) error {
|
||||
keep := []*Comment{}
|
||||
var found bool
|
||||
for _, c := range t.Comments {
|
||||
if c.ID != id {
|
||||
keep = append(keep, c)
|
||||
} else {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return errors.New("comment not found")
|
||||
}
|
||||
|
||||
t.Comments = keep
|
||||
DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("comment not found")
|
||||
}
|
||||
|
||||
// ParseForm populates a Comment from a form.
|
||||
func (c *Comment) ParseForm(r *http.Request) {
|
||||
// Helper function to set an attribute only if the
|
||||
// attribute is currently empty.
|
||||
define := func(target *string, value string) {
|
||||
if value != "" {
|
||||
log.Info("SET DEFINE: %s", value)
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
|
||||
define(&c.ThreadID, r.FormValue("thread"))
|
||||
define(&c.OriginURL, r.FormValue("origin"))
|
||||
define(&c.Subject, r.FormValue("subject"))
|
||||
|
||||
define(&c.Name, r.FormValue("name"))
|
||||
define(&c.Email, r.FormValue("email"))
|
||||
define(&c.Body, r.FormValue("body"))
|
||||
c.Subscribe = r.FormValue("subscribe") == "true"
|
||||
|
||||
// When editing a post
|
||||
c.Editing = r.FormValue("editing") == "true"
|
||||
|
||||
c.Trap1 = r.FormValue("url")
|
||||
c.Trap2 = r.FormValue("comment")
|
||||
|
||||
// Default the timestamp values.
|
||||
if c.Created.IsZero() {
|
||||
c.Created = time.Now().UTC()
|
||||
c.Updated = c.Created
|
||||
} else {
|
||||
c.Updated = time.Now().UTC()
|
||||
}
|
||||
|
||||
c.LoadAvatar()
|
||||
}
|
||||
|
||||
// LoadAvatar calculates the user's avatar for the comment.
|
||||
func (c *Comment) LoadAvatar() {
|
||||
// MD5 hash the email address for Gravatar.
|
||||
if _, err := mail.ParseAddress(c.Email); err == nil {
|
||||
h := md5.New()
|
||||
io.WriteString(h, c.Email)
|
||||
hash := fmt.Sprintf("%x", h.Sum(nil))
|
||||
c.Avatar = fmt.Sprintf(
|
||||
"//www.gravatar.com/avatar/%s?s=96",
|
||||
hash,
|
||||
)
|
||||
} else {
|
||||
// Default gravatar.
|
||||
c.Avatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks the comment's fields for validity.
|
||||
func (c *Comment) Validate() error {
|
||||
// Spambot trap fields.
|
||||
if c.Trap1 != "http://" || c.Trap2 != "" {
|
||||
return errors.New("find a human")
|
||||
}
|
||||
|
||||
// Required metadata fields.
|
||||
if len(c.ThreadID) == 0 {
|
||||
return errors.New("you lost the comment thread ID")
|
||||
} else if len(c.Subject) == 0 {
|
||||
return errors.New("this comment thread is missing a subject")
|
||||
}
|
||||
|
||||
if len(c.Body) == 0 {
|
||||
return errors.New("the message is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -26,6 +26,8 @@ type User struct {
|
|||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
|
||||
IsAuthenticated bool `json:"-"`
|
||||
|
||||
// Whether the user was loaded in read-only mode (no password), so they
|
||||
// can't be saved without a password.
|
||||
readonly bool
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"github.com/kirsle/blog/core/jsondb"
|
||||
)
|
||||
|
||||
// PostHelper is a singleton helper to manage the database controls for blog
|
||||
// entries.
|
||||
type PostHelper struct {
|
||||
master *Blog
|
||||
DB *jsondb.DB
|
||||
}
|
||||
|
||||
// InitPostHelper initializes the blog post controller helper.
|
||||
func InitPostHelper(master *Blog) *PostHelper {
|
||||
return &PostHelper{
|
||||
master: master,
|
||||
DB: master.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIndex loads the blog index (cache).
|
||||
func (p *PostHelper) GetIndex() {}
|
|
@ -98,16 +98,27 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin
|
|||
return err
|
||||
}
|
||||
|
||||
// The comment entry partial.
|
||||
commentEntry, err := b.ResolvePath("comments/entry.partial")
|
||||
if err != nil {
|
||||
log.Error("RenderTemplate(%s): comments/entry.partial not found")
|
||||
return err
|
||||
}
|
||||
|
||||
// Useful template functions.
|
||||
log.Error("HERE!!!")
|
||||
t := template.New(filepath.Absolute).Funcs(template.FuncMap{
|
||||
"StringsJoin": strings.Join,
|
||||
"RenderPost": b.RenderPost,
|
||||
"RenderComments": func(subject string, ids ...string) template.HTML {
|
||||
session := b.Session(r)
|
||||
csrf := b.GenerateCSRFToken(w, r, session)
|
||||
return b.RenderComments(session, csrf, r.URL.Path, subject, ids...)
|
||||
},
|
||||
})
|
||||
|
||||
// Parse the template files. The layout comes first because it's the wrapper
|
||||
// and allows the filepath template to set the page title.
|
||||
t, err = t.ParseFiles(layout.Absolute, filepath.Absolute)
|
||||
t, err = t.ParseFiles(layout.Absolute, commentEntry.Absolute, filepath.Absolute)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
return err
|
||||
|
|
|
@ -17,7 +17,8 @@
|
|||
{{ if $p.EnableComments }}
|
||||
<h2 class="mt-4">Comments</h2>
|
||||
|
||||
TBD.
|
||||
{{ $idStr := printf "%d" $p.ID}}
|
||||
{{ RenderComments $p.Title "post" $idStr }}
|
||||
{{ else }}
|
||||
<hr>
|
||||
<em>Comments are disabled on this post.</em>
|
||||
|
|
97
root/comments/comments.partial.gohtml
Normal file
97
root/comments/comments.partial.gohtml
Normal file
|
@ -0,0 +1,97 @@
|
|||
{{ $t := .Thread }}
|
||||
{{ $a := .Authors }}
|
||||
|
||||
<p>
|
||||
{{- if eq (len $t.Comments) 1 -}}
|
||||
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>
|
||||
</p>
|
||||
|
||||
{{ range $t.Comments }}
|
||||
{{ template "comment" . }}
|
||||
{{ end }}
|
||||
|
||||
<h3 id="add-comment">Add a Comment</h3>
|
||||
|
||||
<form action="/comments" method="POST">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||
<input type="hidden" name="thread" value="{{ .Thread.ID }}">
|
||||
<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>
|
||||
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="preview"
|
||||
class="btn btn-primary">Preview</button>
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="post"
|
||||
class="btn btn-danger">Post</button>
|
||||
</form>
|
53
root/comments/entry.partial.gohtml
Normal file
53
root/comments/entry.partial.gohtml
Normal file
|
@ -0,0 +1,53 @@
|
|||
{{ define "comment" }}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<img src="{{ .Avatar }}"
|
||||
width="96"
|
||||
height="96"
|
||||
alt="Avatar image">
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div class="comment-meta">
|
||||
{{ if and .UserID .Username }}
|
||||
<a href="/u/{{ .Username }}"><strong>{{ or .Name "Anonymous" }}</strong></a>
|
||||
{{ else }}
|
||||
<strong>{{ or .Name "Anonymous" }}</strong>
|
||||
{{ end }}
|
||||
|
||||
posted on {{ .Created.Format "January 2, 2006 @ 15:04 MST" }}
|
||||
|
||||
{{ if .Updated.After .Created }}
|
||||
<span title="{{ .Updated.Format "Jan 2 2006 @ 15:04:05 MST" }}">
|
||||
(updated {{ .Updated.Format "1/2/06 15:04 MST"}})
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ .HTML }}
|
||||
|
||||
{{ if .Editable }}
|
||||
<form action="/comments" method="POST">
|
||||
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
<input type="hidden" name="thread" value="{{ .ThreadID }}">
|
||||
<input type="hidden" name="subject" value="(editing)">
|
||||
<input type="hidden" name="origin" value="{{ .OriginURL }}">
|
||||
<input type="hidden" name="editing" value="true">
|
||||
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="preview"
|
||||
class="btn btn-sm btn-primary">edit</button>
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="delete"
|
||||
class="btn btn-sm btn-danger">delete</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
122
root/comments/index.gohtml
Normal file
122
root/comments/index.gohtml
Normal file
|
@ -0,0 +1,122 @@
|
|||
{{ define "title" }}Preview Comment{{ end }}
|
||||
{{ define "content" }}
|
||||
|
||||
{{ with .Data.Comment }}
|
||||
<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 }}">
|
||||
<input type="hidden" name="origin" value="{{ .OriginURL }}">
|
||||
{{ if $.Data.Editing -}}
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
<input type="hidden" name="editing" value="{{ $.Data.Editing }}">
|
||||
{{ end }}
|
||||
|
||||
<h1>
|
||||
{{- if $.Data.Deleting -}}
|
||||
Delete Comment
|
||||
{{- else if $.Data.Editing -}}
|
||||
Edit Comment
|
||||
{{- else -}}
|
||||
Preview
|
||||
{{- end -}}
|
||||
</h1>
|
||||
|
||||
<hr>
|
||||
|
||||
{{ template "comment" . }}
|
||||
|
||||
<hr>
|
||||
|
||||
{{ if $.Data.Deleting }}
|
||||
<p>Are you sure you want to delete this comment?</p>
|
||||
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="confirm-delete"
|
||||
class="btn btn-danger">
|
||||
Delete Comment
|
||||
</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>
|
||||
|
||||
<button type="submit" name="submit" value="preview" class="btn btn-primary">
|
||||
Refresh Preview
|
||||
</button>
|
||||
<button type="submit" name="submit" value="post" class="btn btn-secondary">
|
||||
Post Comment
|
||||
</button>
|
||||
{{ end }}
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
|
@ -23,6 +23,13 @@ a.blog-title {
|
|||
color: #909;
|
||||
}
|
||||
|
||||
/* Comment metadata line */
|
||||
.comment-meta {
|
||||
font-style: italic;
|
||||
font-size: smaller;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Code blocks treated as <pre> tags */
|
||||
.markdown code {
|
||||
display: block;
|
||||
|
|
Loading…
Reference in New Issue
Block a user