Comment system: add, edit, delete for guests and Admins

This commit is contained in:
Noah 2017-11-26 15:53:10 -08:00
parent ab5430df26
commit 725437d06f
14 changed files with 834 additions and 36 deletions

View File

@ -7,6 +7,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/kirsle/blog/core/jsondb" "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/posts"
"github.com/kirsle/blog/core/models/settings" "github.com/kirsle/blog/core/models/settings"
"github.com/kirsle/blog/core/models/users" "github.com/kirsle/blog/core/models/users"
@ -25,9 +26,6 @@ type Blog struct {
DB *jsondb.DB DB *jsondb.DB
// Helper singletone
Posts *PostHelper
// Web app objects. // Web app objects.
n *negroni.Negroni // Negroni middleware manager n *negroni.Negroni // Negroni middleware manager
r *mux.Router // Router r *mux.Router // Router
@ -41,7 +39,6 @@ func New(documentRoot, userRoot string) *Blog {
UserRoot: userRoot, UserRoot: userRoot,
DB: jsondb.New(filepath.Join(userRoot, ".private")), DB: jsondb.New(filepath.Join(userRoot, ".private")),
} }
blog.Posts = InitPostHelper(blog)
// Load the site config, or start with defaults if not found. // Load the site config, or start with defaults if not found.
settings.DB = blog.DB settings.DB = blog.DB
@ -57,6 +54,7 @@ func New(documentRoot, userRoot string) *Blog {
// Initialize the rest of the models. // Initialize the rest of the models.
posts.DB = blog.DB posts.DB = blog.DB
users.DB = blog.DB users.DB = blog.DB
comments.DB = blog.DB
// Initialize the router. // Initialize the router.
r := mux.NewRouter() r := mux.NewRouter()
@ -65,6 +63,7 @@ func New(documentRoot, userRoot string) *Blog {
r.HandleFunc("/logout", blog.LogoutHandler) r.HandleFunc("/logout", blog.LogoutHandler)
blog.AdminRoutes(r) blog.AdminRoutes(r)
blog.BlogRoutes(r) blog.BlogRoutes(r)
blog.CommentRoutes(r)
// GitHub Flavored Markdown CSS. // GitHub Flavored Markdown CSS.
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))

View File

@ -91,7 +91,7 @@ func (b *Blog) PrivatePosts(w http.ResponseWriter, r *http.Request) {
// PartialIndex handles common logic for blog index views. // PartialIndex handles common logic for blog index views.
func (b *Blog) PartialIndex(w http.ResponseWriter, r *http.Request, func (b *Blog) PartialIndex(w http.ResponseWriter, r *http.Request,
tag, privacy string) { tag, privacy string) {
v := NewVars(map[interface{}]interface{}{}) v := NewVars()
// Get the blog index. // Get the blog index.
idx, _ := posts.GetIndex() 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") filepath, err := b.ResolvePath("blog/entry.partial")
if err != nil { if err != nil {
log.Error(err.Error()) 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 := template.New("entry.partial.gohtml")
t, err = t.ParseFiles(filepath.Absolute) t, err = t.ParseFiles(filepath.Absolute)
if err != nil { if err != nil {
log.Error("Failed to parse entry.partial: %s", err.Error()) 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{ meta := PostMeta{
@ -337,7 +337,7 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML {
err = t.Execute(&output, meta) err = t.Execute(&output, meta)
if err != nil { if err != nil {
log.Error(err.Error()) 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()) return template.HTML(output.String())
@ -372,7 +372,7 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) {
switch r.FormValue("submit") { switch r.FormValue("submit") {
case "preview": case "preview":
if post.ContentType == string(MARKDOWN) { if post.ContentType == string(MARKDOWN) {
v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body)) v.Data["preview"] = template.HTML(b.RenderTrustedMarkdown(post.Body))
} else { } else {
v.Data["preview"] = template.HTML(post.Body) v.Data["preview"] = template.HTML(post.Body)
} }

301
core/comments.go Normal file
View 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
}

View File

@ -23,6 +23,5 @@ func (b *Blog) RenderMarkdown(input string) string {
// Markdown pages, not for user-submitted comments or things. // Markdown pages, not for user-submitted comments or things.
func (b *Blog) RenderTrustedMarkdown(input string) string { func (b *Blog) RenderTrustedMarkdown(input string) string {
html := github_flavored_markdown.Markdown([]byte(input)) html := github_flavored_markdown.Markdown([]byte(input))
log.Info("%s", html)
return string(html) return string(html)
} }

View File

@ -73,6 +73,7 @@ func (b *Blog) CurrentUser(r *http.Request) (*users.User, error) {
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn { if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
id := session.Values["user-id"].(int) id := session.Values["user-id"].(int)
u, err := users.LoadReadonly(id) u, err := users.LoadReadonly(id)
u.IsAuthenticated = true
return u, err return u, err
} }

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

View File

@ -26,6 +26,8 @@ type User struct {
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
IsAuthenticated bool `json:"-"`
// Whether the user was loaded in read-only mode (no password), so they // Whether the user was loaded in read-only mode (no password), so they
// can't be saved without a password. // can't be saved without a password.
readonly bool readonly bool

View File

@ -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() {}

View File

@ -98,16 +98,27 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin
return err 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. // Useful template functions.
log.Error("HERE!!!")
t := template.New(filepath.Absolute).Funcs(template.FuncMap{ t := template.New(filepath.Absolute).Funcs(template.FuncMap{
"StringsJoin": strings.Join, "StringsJoin": strings.Join,
"RenderPost": b.RenderPost, "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 // Parse the template files. The layout comes first because it's the wrapper
// and allows the filepath template to set the page title. // 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 { if err != nil {
log.Error(err.Error()) log.Error(err.Error())
return err return err

View File

@ -17,7 +17,8 @@
{{ if $p.EnableComments }} {{ if $p.EnableComments }}
<h2 class="mt-4">Comments</h2> <h2 class="mt-4">Comments</h2>
TBD. {{ $idStr := printf "%d" $p.ID}}
{{ RenderComments $p.Title "post" $idStr }}
{{ else }} {{ else }}
<hr> <hr>
<em>Comments are disabled on this post.</em> <em>Comments are disabled on this post.</em>

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

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

View File

@ -23,6 +23,13 @@ a.blog-title {
color: #909; color: #909;
} }
/* Comment metadata line */
.comment-meta {
font-style: italic;
font-size: smaller;
margin-bottom: 1rem;
}
/* Code blocks treated as <pre> tags */ /* Code blocks treated as <pre> tags */
.markdown code { .markdown code {
display: block; display: block;