Comment Subscriptions and Recent Comments Page
parent
87fbdea68b
commit
91e3bdaa53
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.kirsle.net/apps/gophertype/pkg/authentication"
|
"git.kirsle.net/apps/gophertype/pkg/authentication"
|
||||||
|
@ -20,9 +21,14 @@ import (
|
||||||
func init() {
|
func init() {
|
||||||
glue.Register(glue.Endpoint{
|
glue.Register(glue.Endpoint{
|
||||||
Path: "/comments",
|
Path: "/comments",
|
||||||
Methods: []string{"POST"},
|
Methods: []string{"GET", "POST"},
|
||||||
Handler: PostComment,
|
Handler: PostComment,
|
||||||
})
|
})
|
||||||
|
glue.Register(glue.Endpoint{
|
||||||
|
Path: "/comments/subscription",
|
||||||
|
Methods: []string{"GET", "POST"},
|
||||||
|
Handler: ManageSubscription,
|
||||||
|
})
|
||||||
glue.Register(glue.Endpoint{
|
glue.Register(glue.Endpoint{
|
||||||
Path: "/comments/quick-delete",
|
Path: "/comments/quick-delete",
|
||||||
Methods: []string{"GET"},
|
Methods: []string{"GET"},
|
||||||
|
@ -109,8 +115,37 @@ func renderComments(w http.ResponseWriter, r *http.Request, subject string, thre
|
||||||
return template.HTML(html.String())
|
return template.HTML(html.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostComment handles all of the top-level blog index routes:
|
// ReadComments handles the GET /comments for viewing recently added comments
|
||||||
|
// side wide.
|
||||||
|
func ReadComments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
v = responses.NewTemplateVars(w, r)
|
||||||
|
query = r.URL.Query()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Query parameters.
|
||||||
|
page, _ := strconv.Atoi(query.Get("page"))
|
||||||
|
perPage, _ := strconv.Atoi(query.Get("per_page"))
|
||||||
|
|
||||||
|
// Get the comments.
|
||||||
|
comments, err := models.Comments.GetRecent(page, perPage)
|
||||||
|
if err != nil {
|
||||||
|
responses.Error(w, r, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v.V["PagedComments"] = comments
|
||||||
|
responses.RenderTemplate(w, r, "_builtin/comments/recent.gohtml", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostComment handles all of the top-level blog index routes
|
||||||
|
// for Post, Preview, Delete comments.
|
||||||
func PostComment(w http.ResponseWriter, r *http.Request) {
|
func PostComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
ReadComments(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
v = responses.NewTemplateVars(w, r)
|
v = responses.NewTemplateVars(w, r)
|
||||||
editToken = getEditToken(w, r)
|
editToken = getEditToken(w, r)
|
||||||
|
@ -190,6 +225,9 @@ func PostComment(w http.ResponseWriter, r *http.Request) {
|
||||||
comment.UserID = currentUser.ID
|
comment.UserID = currentUser.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the OriginURL for this comment.
|
||||||
|
comment.OriginURL = form.Get("origin")
|
||||||
|
|
||||||
// Post their comment.
|
// Post their comment.
|
||||||
err := comment.Save()
|
err := comment.Save()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -213,6 +251,43 @@ func PostComment(w http.ResponseWriter, r *http.Request) {
|
||||||
responses.RenderTemplate(w, r, "_builtin/comments/preview.gohtml", v)
|
responses.RenderTemplate(w, r, "_builtin/comments/preview.gohtml", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManageSubscription helps users unsubscribe from comment threads.
|
||||||
|
func ManageSubscription(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
v = responses.NewTemplateVars(w, r)
|
||||||
|
query = r.URL.Query()
|
||||||
|
qThread = query.Get("t")
|
||||||
|
qEmail = strings.ToLower(strings.TrimSpace(query.Get("e")))
|
||||||
|
qAll = query.Get("all")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Are we unsubscribing from all?
|
||||||
|
if qEmail != "" && qAll != "" {
|
||||||
|
err := models.Comments.UnsubscribeFromAll(qEmail)
|
||||||
|
if err != nil {
|
||||||
|
session.Flash(w, r, "Error unsubscribing you: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Success: '%s' has been unsubscribed from ALL comment threads.", qEmail)
|
||||||
|
}
|
||||||
|
responses.Redirect(w, r, "/comments/subscription")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is there a thread and email in the query string?
|
||||||
|
if qThread != "" && qEmail != "" {
|
||||||
|
err := models.Comments.UnsubscribeThread(qThread, qEmail)
|
||||||
|
if err != nil {
|
||||||
|
session.Flash(w, r, "Error unsubscribing you: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Success: '%s' has been unsubscribed from this comment thread.", qEmail)
|
||||||
|
}
|
||||||
|
responses.Redirect(w, r, "/comments/subscription")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.RenderTemplate(w, r, "_builtin/comments/subscription.gohtml", v)
|
||||||
|
}
|
||||||
|
|
||||||
// CommentQuickDelete handles quick-delete links to remove spam comments.
|
// CommentQuickDelete handles quick-delete links to remove spam comments.
|
||||||
func CommentQuickDelete(w http.ResponseWriter, r *http.Request) {
|
func CommentQuickDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
// Query parameters.
|
// Query parameters.
|
||||||
|
|
|
@ -137,21 +137,25 @@ func NotifyComment(subject string, originURL string, c models.Comment) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// // Email the subscribers.
|
// // Email the subscribers.
|
||||||
// email.Admin = false
|
email.Admin = false
|
||||||
// m := comments.LoadMailingList()
|
if subscribers, err := models.Comments.GetSubscribers(c.Thread); err == nil {
|
||||||
// for _, to := range m.List(c.ThreadID) {
|
email.Subject = "A new comment has been added to: " + subject
|
||||||
// if to == c.Email {
|
|
||||||
// continue // don't email yourself
|
for _, to := range subscribers {
|
||||||
// }
|
// Don't email to the writer of the comment.
|
||||||
// email.To = to
|
if to == c.Email {
|
||||||
// email.UnsubscribeURL = fmt.Sprintf("%s/comments/subscription?t=%s&e=%s",
|
continue
|
||||||
// strings.Trim(s.Site.URL, "/"),
|
}
|
||||||
// url.QueryEscape(c.ThreadID),
|
email.To = to
|
||||||
// url.QueryEscape(to),
|
email.UnsubscribeURL = fmt.Sprintf("%s/comments/subscription?t=%s&e=%s",
|
||||||
// )
|
strings.Trim(s.BaseURL, "/"),
|
||||||
// console.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.ThreadID)
|
url.QueryEscape(c.Thread),
|
||||||
// SendEmail(email)
|
url.QueryEscape(to),
|
||||||
// }
|
)
|
||||||
|
console.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.Thread)
|
||||||
|
SendEmail(email)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseAddress parses an email address.
|
// ParseAddress parses an email address.
|
||||||
|
|
|
@ -2,10 +2,15 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.kirsle.net/apps/gophertype/pkg/console"
|
"git.kirsle.net/apps/gophertype/pkg/console"
|
||||||
"git.kirsle.net/apps/gophertype/pkg/markdown"
|
"git.kirsle.net/apps/gophertype/pkg/markdown"
|
||||||
|
@ -14,6 +19,9 @@ import (
|
||||||
uuid "github.com/satori/go.uuid"
|
uuid "github.com/satori/go.uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Regexp to match a comment thread ID for a blog post
|
||||||
|
var rePostThread = regexp.MustCompile(`^post-(\d+?)$`)
|
||||||
|
|
||||||
type commentMan struct{}
|
type commentMan struct{}
|
||||||
|
|
||||||
// Comments is a singleton manager class for Comment model access.
|
// Comments is a singleton manager class for Comment model access.
|
||||||
|
@ -25,16 +33,31 @@ type Comment struct {
|
||||||
|
|
||||||
Thread string `gorm:"index"` // name of comment thread
|
Thread string `gorm:"index"` // name of comment thread
|
||||||
UserID uint // foreign key to User.ID
|
UserID uint // foreign key to User.ID
|
||||||
|
PostID uint // if a comment on a blog post
|
||||||
|
OriginURL string // original URL of comment page
|
||||||
Name string
|
Name string
|
||||||
Email string
|
Email string
|
||||||
|
Subscribe bool // user subscribes to future comments on same thread
|
||||||
Avatar string
|
Avatar string
|
||||||
Body string
|
Body string
|
||||||
EditToken string // So users can edit their own recent comments.
|
EditToken string // So users can edit their own recent comments.
|
||||||
DeleteToken string `gorm:"unique_index"` // Quick-delete token for spam.
|
DeleteToken string `gorm:"unique_index"` // Quick-delete token for spam.
|
||||||
|
|
||||||
|
Post Post
|
||||||
User User `gorm:"foreign_key:UserID"`
|
User User `gorm:"foreign_key:UserID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PagedComments holds a paginated view of multiple comments.
|
||||||
|
type PagedComments struct {
|
||||||
|
Comments []Comment
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Pages int
|
||||||
|
Total int
|
||||||
|
NextPage int
|
||||||
|
PreviousPage int
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new Comment model.
|
// New creates a new Comment model.
|
||||||
func (m commentMan) New() Comment {
|
func (m commentMan) New() Comment {
|
||||||
return Comment{
|
return Comment{
|
||||||
|
@ -45,7 +68,7 @@ func (m commentMan) New() Comment {
|
||||||
// Load a comment by ID.
|
// Load a comment by ID.
|
||||||
func (m commentMan) Load(id int) (Comment, error) {
|
func (m commentMan) Load(id int) (Comment, error) {
|
||||||
var com Comment
|
var com Comment
|
||||||
r := DB.Preload("User").First(&com, id)
|
r := DB.Preload("User").Preload("Post").First(&com, id)
|
||||||
return com, r.Error
|
return com, r.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,6 +89,85 @@ func (m commentMan) GetThread(thread string) ([]Comment, error) {
|
||||||
return coms, r.Error
|
return coms, r.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRecent pages through the comments by recency.
|
||||||
|
func (m commentMan) GetRecent(page int, perPage int) (PagedComments, error) {
|
||||||
|
var pc = PagedComments{
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
if pc.Page < 1 {
|
||||||
|
pc.Page = 1
|
||||||
|
}
|
||||||
|
if pc.PerPage <= 0 {
|
||||||
|
pc.PerPage = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
query := DB.Debug().Preload("User").Preload("Post").
|
||||||
|
Order("created_at desc")
|
||||||
|
|
||||||
|
// Count the total number of rows.
|
||||||
|
query.Model(&Comment{}).Count(&pc.Total)
|
||||||
|
|
||||||
|
// Query the paged slice of results.
|
||||||
|
r := query.
|
||||||
|
Offset((pc.Page - 1) * pc.PerPage).
|
||||||
|
Limit(pc.PerPage).
|
||||||
|
Find(&pc.Comments)
|
||||||
|
|
||||||
|
// Populate paging details.
|
||||||
|
pc.Pages = int(math.Ceil(float64(pc.Total) / float64(pc.PerPage)))
|
||||||
|
if pc.Page < pc.Pages {
|
||||||
|
pc.NextPage = pc.Page + 1
|
||||||
|
}
|
||||||
|
if pc.Page > 1 {
|
||||||
|
pc.PreviousPage = pc.Page - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return pc, r.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubscribers returns the subscriber email addresses that are watching a comment thread.
|
||||||
|
func (m commentMan) GetSubscribers(thread string) ([]string, error) {
|
||||||
|
var result []string
|
||||||
|
|
||||||
|
var comments []Comment
|
||||||
|
r := DB.Where("thread = ? AND subscribe = ?", thread, true).Find(&comments)
|
||||||
|
|
||||||
|
// Filter them down to valid emails only.
|
||||||
|
for _, com := range comments {
|
||||||
|
// TODO: validate its an email
|
||||||
|
if len(com.Email) > 0 {
|
||||||
|
result = append(result, com.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, r.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeThread unsubscribes a user's email from a comment thread.
|
||||||
|
func (m commentMan) UnsubscribeThread(thread string, email string) error {
|
||||||
|
// Verify the thread is valid.
|
||||||
|
var count int
|
||||||
|
DB.Debug().Model(&Comment{}).Where("thread=?", thread).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
return errors.New("invalid comment thread")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := DB.Debug().Table("comments").Where("thread=? AND subscribe=?", thread, true).Updates(map[string]interface{}{
|
||||||
|
"subscribe": false,
|
||||||
|
})
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeFromAll remove's an email subscription for ALL comment threads.
|
||||||
|
func (m commentMan) UnsubscribeFromAll(email string) error {
|
||||||
|
r := DB.Debug().Table("comments").Where("email=?", email).Updates(map[string]interface{}{
|
||||||
|
"subscribe": false,
|
||||||
|
})
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
|
||||||
// HTML returns the comment's body as rendered HTML code.
|
// HTML returns the comment's body as rendered HTML code.
|
||||||
func (c Comment) HTML() template.HTML {
|
func (c Comment) HTML() template.HTML {
|
||||||
return template.HTML(markdown.RenderMarkdown(c.Body))
|
return template.HTML(markdown.RenderMarkdown(c.Body))
|
||||||
|
@ -96,6 +198,21 @@ func (c *Comment) Save() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse the thread name if it looks like a post ID.
|
||||||
|
if m := rePostThread.FindStringSubmatch(c.Thread); len(m) > 0 {
|
||||||
|
if postID, err := strconv.Atoi(m[1]); err == nil {
|
||||||
|
c.PostID = uint(postID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's a PostID, validate that the post exists.
|
||||||
|
if c.PostID > 0 {
|
||||||
|
if _, err := Posts.Load(int(c.PostID)); err != nil {
|
||||||
|
console.Error("Comment had a PostID=%d but the post wasn't found!", c.PostID)
|
||||||
|
c.PostID = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.Info("Save comment: %+v", c)
|
console.Info("Save comment: %+v", c)
|
||||||
|
|
||||||
// Save the post.
|
// Save the post.
|
||||||
|
@ -116,8 +233,9 @@ func (c Comment) Delete() error {
|
||||||
func (c *Comment) ParseForm(form *forms.Data) {
|
func (c *Comment) ParseForm(form *forms.Data) {
|
||||||
c.Thread = form.Get("thread")
|
c.Thread = form.Get("thread")
|
||||||
c.Name = form.Get("name")
|
c.Name = form.Get("name")
|
||||||
c.Email = form.Get("email")
|
c.Email = strings.ToLower(strings.TrimSpace(form.Get("email")))
|
||||||
c.Body = form.Get("body")
|
c.Body = form.Get("body")
|
||||||
|
c.Subscribe = form.Get("subscribe") == "true"
|
||||||
c.LoadAvatar()
|
c.LoadAvatar()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -98,8 +98,8 @@ func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, e
|
||||||
|
|
||||||
// Query the paginated slice of results.
|
// Query the paginated slice of results.
|
||||||
r := query.
|
r := query.
|
||||||
Offset((page - 1) * perPage).
|
Offset((pp.Page - 1) * pp.PerPage).
|
||||||
Limit(perPage).
|
Limit(pp.PerPage).
|
||||||
Find(&pp.Posts)
|
Find(&pp.Posts)
|
||||||
|
|
||||||
pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
|
pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
|
||||||
|
|
|
@ -102,6 +102,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title">Pages</h4>
|
||||||
|
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li class="list-item"><a href="/comments">Recent Comments</a></li>
|
||||||
|
<li class="list-item"><a href="/guestbook">Guestbook</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ if .LoggedIn }}
|
{{ if .LoggedIn }}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
@ -30,6 +30,13 @@
|
||||||
<small id="emailHelp" class="form-text text-muted">
|
<small id="emailHelp" class="form-text text-muted">
|
||||||
Optional; used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>.
|
Optional; used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>.
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
|
|
||||||
|
<p class="mt-1 text-muted">
|
||||||
|
<label style="font-weight: normal">
|
||||||
|
<input type="checkbox" name="subscribe" value="true"{{ if $NC.Subscribe }} checked{{ end }}> Notify me of future comments on this page
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
{{ define "title" }}Recent Comments{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
|
||||||
|
<h1>Recent Comments</h1>
|
||||||
|
|
||||||
|
{{ with .V.PagedComments }}
|
||||||
|
<p>
|
||||||
|
Page {{ .Page }} of {{ .Pages }} ({{ .Total }} total)
|
||||||
|
{{ if or (gt .PreviousPage 0) (gt .NextPage 0) }}
|
||||||
|
[
|
||||||
|
{{ if gt .NextPage 0 }}
|
||||||
|
<a href="/comments?page={{ .NextPage }}&per_page={{ .PerPage }}">Older</a>
|
||||||
|
{{ if gt .PreviousPage 0 }} | {{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ if gt .PreviousPage 0 }}
|
||||||
|
<a href="/comments?page={{ .PreviousPage }}&per_page={{ .PerPage }}">Newer</a>
|
||||||
|
{{ end }}
|
||||||
|
]
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{ range .Comments }}
|
||||||
|
{{ if gt .PostID 0 }}
|
||||||
|
<p>
|
||||||
|
<strong>In post <a href="{{ .Post.Fragment }}">{{ or .Post.Title "Untitled" }}</a>:</strong>
|
||||||
|
</p>
|
||||||
|
{{ else if .OriginURL }}
|
||||||
|
<p>
|
||||||
|
<strong>On page <a href="{{ .OriginURL }}">{{ .OriginURL }}</a>:</strong>
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ RenderComment $.ResponseWriter $.Request . "/comments" false }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ end }}
|
|
@ -0,0 +1,24 @@
|
||||||
|
{{ define "title" }}Comment Subscriptions{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
|
||||||
|
<h1>Comment Subscriptions</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This blog allows users to subscribe to comment threads, when they leave their
|
||||||
|
e-mail address and opt-in to do so when adding a comment to a page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To unsubscribe from a single comment thread, click on the "Unsubscribe" link
|
||||||
|
in that email. Or, to remove yourself from <strong>all comment threads</strong>,
|
||||||
|
enter your email address below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="/comments/subscription" method="GET">
|
||||||
|
<input type="hidden" name="all" value="true">
|
||||||
|
<h2>Unsubscribe From All</h2>
|
||||||
|
|
||||||
|
<input type="email" class="form-control" name="e" placeholder="Email address">
|
||||||
|
<button type="submit" class="btn btn-primary mt-2">Unsubscribe From All</button>
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
|
@ -0,0 +1,8 @@
|
||||||
|
{{ define "title" }}Guestbook{{ end }}
|
||||||
|
{{ define "content" }}
|
||||||
|
|
||||||
|
<h1>My Guestbook</h1>
|
||||||
|
|
||||||
|
{{ RenderComments .ResponseWriter .Request "My Guestbook" "guestbook" }}
|
||||||
|
|
||||||
|
{{ end }}
|
Loading…
Reference in New Issue