diff --git a/core/blog.go b/core/blog.go deleted file mode 100644 index 1955e8d..0000000 --- a/core/blog.go +++ /dev/null @@ -1,553 +0,0 @@ -package core - -import ( - "bytes" - "errors" - "fmt" - "html/template" - "net/http" - "sort" - "strconv" - "strings" - "time" - - "github.com/gorilla/feeds" - "github.com/gorilla/mux" - "github.com/kirsle/blog/core/internal/log" - "github.com/kirsle/blog/core/internal/markdown" - "github.com/kirsle/blog/core/internal/middleware/auth" - "github.com/kirsle/blog/core/internal/models/comments" - "github.com/kirsle/blog/core/internal/models/posts" - "github.com/kirsle/blog/core/internal/models/settings" - "github.com/kirsle/blog/core/internal/models/users" - "github.com/kirsle/blog/core/internal/render" - "github.com/kirsle/blog/core/internal/responses" - "github.com/urfave/negroni" -) - -// PostMeta associates a Post with injected metadata. -type PostMeta struct { - Post *posts.Post - Rendered template.HTML - Author *users.User - NumComments int - IndexView bool - Snipped bool -} - -// Archive holds data for a piece of the blog archive. -type Archive struct { - Label string - Date time.Time - Posts []posts.Post -} - -// BlogRoutes attaches the blog routes to the app. -func (b *Blog) BlogRoutes(r *mux.Router) { - render.Funcs["RenderIndex"] = b.RenderIndex - render.Funcs["RenderPost"] = b.RenderPost - render.Funcs["RenderTags"] = b.RenderTags - - // Public routes - r.HandleFunc("/blog", b.IndexHandler) - r.HandleFunc("/blog.rss", b.RSSHandler) - r.HandleFunc("/blog.atom", b.RSSHandler) - r.HandleFunc("/archive", b.BlogArchive) - r.HandleFunc("/tagged", b.Tagged) - r.HandleFunc("/tagged/{tag}", b.Tagged) - r.HandleFunc("/blog/category/{tag}", func(w http.ResponseWriter, r *http.Request) { - params := mux.Vars(r) - tag, ok := params["tag"] - if !ok { - b.NotFound(w, r, "Not Found") - return - } - responses.Redirect(w, "/tagged/"+tag) - }) - r.HandleFunc("/blog/entry/{fragment}", func(w http.ResponseWriter, r *http.Request) { - params := mux.Vars(r) - fragment, ok := params["fragment"] - if !ok { - b.NotFound(w, r, "Not Found") - return - } - responses.Redirect(w, "/"+fragment) - }) - - // Login-required routers. - loginRouter := mux.NewRouter() - loginRouter.HandleFunc("/blog/edit", b.EditBlog) - loginRouter.HandleFunc("/blog/delete", b.DeletePost) - loginRouter.HandleFunc("/blog/drafts", b.Drafts) - loginRouter.HandleFunc("/blog/private", b.PrivatePosts) - r.PathPrefix("/blog").Handler( - negroni.New( - negroni.HandlerFunc(auth.LoginRequired(b.MustLogin)), - negroni.Wrap(loginRouter), - ), - ) -} - -// RSSHandler renders an RSS feed from the blog. -func (b *Blog) RSSHandler(w http.ResponseWriter, r *http.Request) { - config, _ := settings.Load() - admin, err := users.Load(1) - if err != nil { - b.Error(w, r, "Blog isn't ready yet.") - return - } - - feed := &feeds.Feed{ - Title: config.Site.Title, - Link: &feeds.Link{Href: config.Site.URL}, - Description: config.Site.Description, - Author: &feeds.Author{ - Name: admin.Name, - Email: admin.Email, - }, - Created: time.Now(), - } - - feed.Items = []*feeds.Item{} - for i, p := range b.RecentPosts(r, "", "") { - post, _ := posts.Load(p.ID) - var suffix string - if strings.Contains(post.Body, "") { - post.Body = strings.Split(post.Body, "")[0] - suffix = "..." - } - - feed.Items = append(feed.Items, &feeds.Item{ - Title: p.Title, - Link: &feeds.Link{Href: config.Site.URL + p.Fragment}, - Description: post.Body + suffix, - Created: p.Created, - }) - if i >= 5 { - break - } - } - - // What format to encode it in? - if strings.Contains(r.URL.Path, ".atom") { - atom, _ := feed.ToAtom() - w.Header().Set("Content-Type", "application/atom+xml") - w.Write([]byte(atom)) - } else { - rss, _ := feed.ToRss() - w.Header().Set("Content-Type", "application/rss+xml") - w.Write([]byte(rss)) - } -} - -// IndexHandler renders the main index page of the blog. -func (b *Blog) IndexHandler(w http.ResponseWriter, r *http.Request) { - b.CommonIndexHandler(w, r, "", "") -} - -// Tagged lets you browse blog posts by category. -func (b *Blog) Tagged(w http.ResponseWriter, r *http.Request) { - params := mux.Vars(r) - tag, ok := params["tag"] - if !ok { - // They're listing all the tags. - render.Template(w, r, "blog/tags.gohtml", nil) - return - } - - b.CommonIndexHandler(w, r, tag, "") -} - -// Drafts renders an index view of only draft posts. Login required. -func (b *Blog) Drafts(w http.ResponseWriter, r *http.Request) { - b.CommonIndexHandler(w, r, "", DRAFT) -} - -// PrivatePosts renders an index view of only private posts. Login required. -func (b *Blog) PrivatePosts(w http.ResponseWriter, r *http.Request) { - b.CommonIndexHandler(w, r, "", PRIVATE) -} - -// CommonIndexHandler handles common logic for blog index views. -func (b *Blog) CommonIndexHandler(w http.ResponseWriter, r *http.Request, tag, privacy string) { - // Page title. - var title string - if privacy == DRAFT { - title = "Draft Posts" - } else if privacy == PRIVATE { - title = "Private Posts" - } else if tag != "" { - title = "Tagged as: " + tag - } else { - title = "Blog" - } - - render.Template(w, r, "blog/index", map[string]interface{}{ - "Title": title, - "Tag": tag, - "Privacy": privacy, - }) -} - -// RecentPosts gets and filters the blog entries and orders them by most recent. -func (b *Blog) RecentPosts(r *http.Request, tag, privacy string) []posts.Post { - // Get the blog index. - idx, _ := posts.GetIndex() - - // The set of blog posts to show. - var pool []posts.Post - for _, post := range idx.Posts { - // Limiting by a specific privacy setting? (drafts or private only) - if privacy != "" { - switch privacy { - case DRAFT: - if post.Privacy != DRAFT { - continue - } - case PRIVATE: - if post.Privacy != PRIVATE && post.Privacy != UNLISTED { - continue - } - } - } else { - // Exclude certain posts in generic index views. - if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !auth.LoggedIn(r) { - continue - } else if post.Privacy == DRAFT { - continue - } - } - - // Limit by tag? - if tag != "" { - var tagMatch bool - if tag != "" { - for _, check := range post.Tags { - if check == tag { - tagMatch = true - break - } - } - } - - if !tagMatch { - continue - } - } - - pool = append(pool, post) - } - - sort.Sort(sort.Reverse(posts.ByUpdated(pool))) - return pool -} - -// RenderIndex renders and returns the blog index partial. -func (b *Blog) RenderIndex(r *http.Request, tag, privacy string) template.HTML { - // Get the recent blog entries, filtered by the tag/privacy settings. - pool := b.RecentPosts(r, tag, privacy) - if len(pool) == 0 { - return template.HTML("No blog posts were found.") - } - - // Query parameters. - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - if page <= 0 { - page = 1 - } - perPage := 5 // TODO: configurable - offset := (page - 1) * perPage - stop := offset + perPage - - // Handle pagination. - var previousPage, nextPage int - if page > 1 { - previousPage = page - 1 - } else { - previousPage = 0 - } - if offset+perPage < len(pool) { - nextPage = page + 1 - } else { - nextPage = 0 - } - - var view []PostMeta - for i := offset; i < stop; i++ { - if i >= len(pool) { - continue - } - post, err := posts.Load(pool[i].ID) - if err != nil { - log.Error("couldn't load full post data for ID %d (found in index.json)", pool[i].ID) - continue - } - - // Look up the author's information. - author, err := users.LoadReadonly(post.AuthorID) - if err != nil { - log.Error("Failed to look up post author ID %d (post %d): %v", post.AuthorID, post.ID, err) - 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, - Author: author, - NumComments: numComments, - }) - } - - // Render the blog index partial. - var output bytes.Buffer - v := map[string]interface{}{ - "PreviousPage": previousPage, - "NextPage": nextPage, - "View": view, - } - render.Template(&output, r, "blog/index.partial", v) - - return template.HTML(output.String()) -} - -// RenderTags renders the tags partial. -func (b *Blog) RenderTags(r *http.Request, indexView bool) template.HTML { - idx, err := posts.GetIndex() - if err != nil { - return template.HTML("[RenderTags: error getting blog index]") - } - - tags, err := idx.Tags() - if err != nil { - return template.HTML("[RenderTags: error getting tags]") - } - - var output bytes.Buffer - v := map[string]interface{}{ - "IndexView": indexView, - "Tags": tags, - } - render.Template(&output, r, "blog/tags.partial", v) - - return template.HTML(output.String()) -} - -// BlogArchive summarizes all blog entries in an archive view. -func (b *Blog) BlogArchive(w http.ResponseWriter, r *http.Request) { - idx, err := posts.GetIndex() - if err != nil { - b.BadRequest(w, r, "Error getting blog index") - return - } - - // Group posts by calendar month. - var months []string - byMonth := map[string]*Archive{} - for _, post := range idx.Posts { - // Exclude certain posts - if (post.Privacy == PRIVATE || post.Privacy == UNLISTED) && !auth.LoggedIn(r) { - continue - } else if post.Privacy == DRAFT { - continue - } - - label := post.Created.Format("2006-01") - if _, ok := byMonth[label]; !ok { - months = append(months, label) - byMonth[label] = &Archive{ - Label: label, - Date: time.Date(post.Created.Year(), post.Created.Month(), post.Created.Day(), 0, 0, 0, 0, time.UTC), - Posts: []posts.Post{}, - } - } - byMonth[label].Posts = append(byMonth[label].Posts, post) - } - - // Sort the months. - sort.Sort(sort.Reverse(sort.StringSlice(months))) - - // Prepare the response. - result := []*Archive{} - for _, label := range months { - sort.Sort(sort.Reverse(posts.ByUpdated(byMonth[label].Posts))) - result = append(result, byMonth[label]) - } - - v := map[string]interface{}{ - "Archive": result, - } - render.Template(w, r, "blog/archive", v) -} - -// viewPost is the underlying implementation of the handler to view a blog -// post, so that it can be called from non-http.HandlerFunc contexts. -// Specifically, from the catch-all page handler to allow blog URL fragments -// to map to their post. -func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string) error { - post, err := posts.LoadFragment(fragment) - if err != nil { - return err - } - - // Handle post privacy. - if post.Privacy == PRIVATE || post.Privacy == DRAFT { - if !auth.LoggedIn(r) { - b.NotFound(w, r, "That post is not public.") - return nil - } - } - - v := map[string]interface{}{ - "Post": post, - } - render.Template(w, r, "blog/entry", v) - - return nil -} - -// 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(r *http.Request, p *posts.Post, indexView bool, numComments int) template.HTML { - // Look up the author's information. - author, err := users.LoadReadonly(p.AuthorID) - if err != nil { - log.Error("Failed to look up post author ID %d (post %d): %v", p.AuthorID, p.ID, err) - author = users.DeletedUser() - } - - // "Read More" snippet for index views. - var snipped bool - if indexView { - if strings.Contains(p.Body, "") { - parts := strings.SplitN(p.Body, "", 2) - p.Body = parts[0] - snipped = true - } - } - - p.Body = strings.Replace(p.Body, "", "
", 1) - - // Render the post to HTML. - var rendered template.HTML - if p.ContentType == string(MARKDOWN) { - rendered = template.HTML(markdown.RenderTrustedMarkdown(p.Body)) - } else { - rendered = template.HTML(p.Body) - } - - meta := map[string]interface{}{ - "Post": p, - "Rendered": rendered, - "Author": author, - "IndexView": indexView, - "Snipped": snipped, - "NumComments": numComments, - } - output := bytes.Buffer{} - err = render.Template(&output, r, "blog/entry.partial", meta) - if err != nil { - return template.HTML(fmt.Sprintf("[template error in blog/entry.partial: %s]", err.Error())) - } - - return template.HTML(output.String()) -} - -// EditBlog is the blog writing and editing page. -func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { - v := map[string]interface{}{ - "preview": "", - } - var post *posts.Post - - // Are we editing an existing post? - if idStr := r.FormValue("id"); idStr != "" { - id, err := strconv.Atoi(idStr) - if err == nil { - post, err = posts.Load(id) - if err != nil { - v["Error"] = errors.New("that post ID was not found") - post = posts.New() - } - } - } else { - post = posts.New() - } - - if r.Method == http.MethodPost { - // Parse from form values. - post.ParseForm(r) - - // Previewing, or submitting? - switch r.FormValue("submit") { - case "preview": - if post.ContentType == string(MARKDOWN) { - v["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) - } else { - v["preview"] = template.HTML(post.Body) - } - case "post": - if err := post.Validate(); err != nil { - v["Error"] = err - } else { - author, _ := auth.CurrentUser(r) - post.AuthorID = author.ID - - post.Updated = time.Now().UTC() - err = post.Save() - if err != nil { - v["Error"] = err - } else { - responses.Flash(w, r, "Post created!") - responses.Redirect(w, "/"+post.Fragment) - } - } - } - } - - v["post"] = post - render.Template(w, r, "blog/edit", v) -} - -// DeletePost to delete a blog entry. -func (b *Blog) DeletePost(w http.ResponseWriter, r *http.Request) { - var post *posts.Post - v := map[string]interface{}{ - "Post": nil, - } - - var idStr string - if r.Method == http.MethodPost { - idStr = r.FormValue("id") - } else { - idStr = r.URL.Query().Get("id") - } - if idStr == "" { - responses.FlashAndRedirect(w, r, "/admin", "No post ID given for deletion!") - return - } - - // Convert the post ID to an int. - id, err := strconv.Atoi(idStr) - if err == nil { - post, err = posts.Load(id) - if err != nil { - responses.FlashAndRedirect(w, r, "/admin", "That post ID was not found.") - return - } - } - - if r.Method == http.MethodPost { - post.Delete() - responses.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!") - return - } - - v["Post"] = post - render.Template(w, r, "blog/delete", v) -} diff --git a/core/core.go b/core/core.go index 1f96907..d8d78cb 100644 --- a/core/core.go +++ b/core/core.go @@ -9,7 +9,9 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/internal/controllers/admin" "github.com/kirsle/blog/core/internal/controllers/authctl" + commentctl "github.com/kirsle/blog/core/internal/controllers/comments" "github.com/kirsle/blog/core/internal/controllers/contact" + postctl "github.com/kirsle/blog/core/internal/controllers/posts" "github.com/kirsle/blog/core/internal/controllers/setup" "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/markdown" @@ -113,9 +115,9 @@ func (b *Blog) SetupHTTP() { setup.Register(r) authctl.Register(r) admin.Register(r, b.MustLogin) - contact.Register(r, b.Error) - b.BlogRoutes(r) - b.CommentRoutes(r) + contact.Register(r) + postctl.Register(r, b.MustLogin) + commentctl.Register(r) // GitHub Flavored Markdown CSS. r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) @@ -127,7 +129,7 @@ func (b *Blog) SetupHTTP() { negroni.NewRecovery(), negroni.NewLogger(), negroni.HandlerFunc(sessions.Middleware), - negroni.HandlerFunc(middleware.CSRF(b.Forbidden)), + negroni.HandlerFunc(middleware.CSRF(responses.Forbidden)), negroni.HandlerFunc(auth.Middleware), ) n.UseHandler(r) diff --git a/core/errors.go b/core/errors.go index 8984360..52175cf 100644 --- a/core/errors.go +++ b/core/errors.go @@ -5,56 +5,57 @@ import ( "github.com/kirsle/blog/core/internal/log" "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" ) -// NotFound sends a 404 response. -func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message string) { - if message == "" { - message = "The page you were looking for was not found." +// registerErrors loads the error handlers into the responses subpackage. +func (b *Blog) registerErrors() { + responses.NotFound = func(w http.ResponseWriter, r *http.Request, message string) { + if message == "" { + message = "The page you were looking for was not found." + } + + w.WriteHeader(http.StatusNotFound) + err := render.Template(w, r, ".errors/404", map[string]string{ + "Message": message, + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for NotFound()")) + } } - w.WriteHeader(http.StatusNotFound) - err := render.Template(w, r, ".errors/404", map[string]string{ - "Message": message, - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for NotFound()")) + responses.Forbidden = func(w http.ResponseWriter, r *http.Request, message string) { + w.WriteHeader(http.StatusForbidden) + err := render.Template(w, r, ".errors/403", map[string]string{ + "Message": message, + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for Forbidden()")) + } } -} -// Forbidden sends an HTTP 403 Forbidden response. -func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message string) { - w.WriteHeader(http.StatusForbidden) - err := render.Template(w, r, ".errors/403", map[string]string{ - "Message": message, - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for Forbidden()")) + responses.Error = func(w http.ResponseWriter, r *http.Request, message string) { + w.WriteHeader(http.StatusInternalServerError) + err := render.Template(w, r, ".errors/500", map[string]string{ + "Message": message, + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for Error()")) + } } -} -// Error sends an HTTP 500 Internal Server Error response. -func (b *Blog) Error(w http.ResponseWriter, r *http.Request, message string) { - w.WriteHeader(http.StatusInternalServerError) - err := render.Template(w, r, ".errors/500", map[string]string{ - "Message": message, - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for Error()")) + responses.BadRequest = func(w http.ResponseWriter, r *http.Request, message string) { + w.WriteHeader(http.StatusBadRequest) + err := render.Template(w, r, ".errors/400", map[string]string{ + "Message": message, + }) + if err != nil { + log.Error(err.Error()) + w.Write([]byte("Unrecoverable template error for BadRequest()")) + } } -} -// BadRequest sends an HTTP 400 Bad Request. -func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message string) { - w.WriteHeader(http.StatusBadRequest) - err := render.Template(w, r, ".errors/400", map[string]string{ - "Message": message, - }) - if err != nil { - log.Error(err.Error()) - w.Write([]byte("Unrecoverable template error for BadRequest()")) - } } diff --git a/core/comments.go b/core/internal/controllers/comments/comments.go similarity index 76% rename from core/comments.go rename to core/internal/controllers/comments/comments.go index ba5164a..8ee594a 100644 --- a/core/comments.go +++ b/core/internal/controllers/comments/comments.go @@ -1,4 +1,4 @@ -package core +package comments import ( "bytes" @@ -19,13 +19,16 @@ import ( "github.com/kirsle/blog/core/internal/sessions" ) -// CommentRoutes attaches the comment routes to the app. -func (b *Blog) CommentRoutes(r *mux.Router) { - render.Funcs["RenderComments"] = b.RenderComments +var badRequest func(http.ResponseWriter, *http.Request, string) - r.HandleFunc("/comments", b.CommentHandler) - r.HandleFunc("/comments/subscription", b.SubscriptionHandler) - r.HandleFunc("/comments/quick-delete", b.QuickDeleteHandler) +// Register the comment routes to the app. +func Register(r *mux.Router) { + badRequest = responses.BadRequest + render.Funcs["RenderComments"] = RenderComments + + r.HandleFunc("/comments", commentHandler) + r.HandleFunc("/comments/subscription", subscriptionHandler) + r.HandleFunc("/comments/quick-delete", quickDeleteHandler) } // CommentMeta is the template variables for comment threads. @@ -40,7 +43,7 @@ type CommentMeta struct { } // RenderComments renders a comment form partial and returns the HTML. -func (b *Blog) RenderComments(r *http.Request, subject string, ids ...string) template.HTML { +func RenderComments(r *http.Request, subject string, ids ...string) template.HTML { id := strings.Join(ids, "-") session := sessions.Get(r) url := r.URL.Path @@ -141,14 +144,13 @@ func (b *Blog) RenderComments(r *http.Request, subject string, ids ...string) te return template.HTML(output.String()) } -// CommentHandler handles the /comments URI for previewing and posting. -func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { +func commentHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - b.BadRequest(w, r, "That method is not allowed.") + badRequest(w, r, "That method is not allowed.") return } currentUser, _ := auth.CurrentUser(r) - editToken := b.GetEditToken(w, r) + editToken := getEditToken(w, r) submit := r.FormValue("submit") // Load the comment data from the form. @@ -207,14 +209,14 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { // Previewing, deleting, or posting? switch submit { - case ActionPreview, ActionDelete: + case "preview", "delete": if !c.Editing && currentUser.IsAuthenticated { c.Name = currentUser.Name c.Email = currentUser.Email c.LoadAvatar() } c.HTML = template.HTML(markdown.RenderMarkdown(c.Body)) - case ActionPost: + case "post": if err := c.Validate(); err != nil { v["Error"] = err } else { @@ -258,55 +260,22 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { v["Thread"] = t v["Comment"] = c v["Editing"] = c.Editing - v["Deleting"] = submit == ActionDelete + v["Deleting"] = submit == "delete" render.Template(w, r, "comments/index.gohtml", v) } -// SubscriptionHandler to opt out of subscriptions. -func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) { - // POST to unsubscribe from all threads. - if r.Method == http.MethodPost { - email := r.FormValue("email") - if email == "" { - b.BadRequest(w, r, "email address is required to unsubscribe from comment threads") - } else if _, err := mail.ParseAddress(email); err != nil { - b.BadRequest(w, r, "invalid email address") - } - - m := comments.LoadMailingList() - m.UnsubscribeAll(email) - responses.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) - responses.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.") - return - } - - render.Template(w, r, "comments/subscription.gohtml", nil) -} - -// QuickDeleteHandler allows the admin to quickly delete spam without logging in. -func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) { +func 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, "Bad Request") + badRequest(w, r, "Bad Request") return } t, err := comments.Load(thread) if err != nil { - b.BadRequest(w, r, "Comment thread does not exist.") + badRequest(w, r, "Comment thread does not exist.") return } @@ -317,9 +286,9 @@ func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) { responses.FlashAndRedirect(w, r, "/", "Comment deleted!") } -// GetEditToken gets or generates an edit token from the user's session, which +// 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 { +func getEditToken(w http.ResponseWriter, r *http.Request) string { session := sessions.Get(r) if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 { return token diff --git a/core/internal/controllers/comments/subscriptions.go b/core/internal/controllers/comments/subscriptions.go new file mode 100644 index 0000000..58e217c --- /dev/null +++ b/core/internal/controllers/comments/subscriptions.go @@ -0,0 +1,41 @@ +package comments + +import ( + "net/http" + "net/mail" + + "github.com/kirsle/blog/core/internal/models/comments" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" +) + +func subscriptionHandler(w http.ResponseWriter, r *http.Request) { + // POST to unsubscribe from all threads. + if r.Method == http.MethodPost { + email := r.FormValue("email") + if email == "" { + badRequest(w, r, "email address is required to unsubscribe from comment threads") + } else if _, err := mail.ParseAddress(email); err != nil { + badRequest(w, r, "invalid email address") + } + + m := comments.LoadMailingList() + m.UnsubscribeAll(email) + responses.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) + responses.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.") + return + } + + render.Template(w, r, "comments/subscription.gohtml", nil) +} diff --git a/core/internal/controllers/contact/contact.go b/core/internal/controllers/contact/contact.go index dfcee94..7d3a444 100644 --- a/core/internal/controllers/contact/contact.go +++ b/core/internal/controllers/contact/contact.go @@ -18,7 +18,7 @@ import ( ) // Register attaches the contact URL to the app. -func Register(r *mux.Router, onError func(http.ResponseWriter, *http.Request, string)) { +func Register(r *mux.Router) { r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) { form := &forms.Contact{} v := map[string]interface{}{ @@ -28,13 +28,13 @@ func Register(r *mux.Router, onError func(http.ResponseWriter, *http.Request, st // If there is no site admin, show an error. cfg, err := settings.Load() if err != nil { - onError(w, r, "Error loading site configuration!") + responses.Error(w, r, "Error loading site configuration!") return } else if cfg.Site.AdminEmail == "" { - onError(w, r, "There is no admin email configured for this website!") + responses.Error(w, r, "There is no admin email configured for this website!") return } else if !cfg.Mail.Enabled { - onError(w, r, "This website doesn't have an e-mail gateway configured.") + responses.Error(w, r, "This website doesn't have an e-mail gateway configured.") return } diff --git a/core/internal/controllers/posts/archive.go b/core/internal/controllers/posts/archive.go new file mode 100644 index 0000000..4362723 --- /dev/null +++ b/core/internal/controllers/posts/archive.go @@ -0,0 +1,60 @@ +package postctl + +import ( + "net/http" + "sort" + "time" + + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" + "github.com/kirsle/blog/core/internal/types" +) + +// archiveHandler summarizes all blog entries in an archive view. +func archiveHandler(w http.ResponseWriter, r *http.Request) { + idx, err := posts.GetIndex() + if err != nil { + responses.BadRequest(w, r, "Error getting blog index") + return + } + + // Group posts by calendar month. + var months []string + byMonth := map[string]*Archive{} + for _, post := range idx.Posts { + // Exclude certain posts + if (post.Privacy == types.PRIVATE || post.Privacy == types.UNLISTED) && !auth.LoggedIn(r) { + continue + } else if post.Privacy == types.DRAFT { + continue + } + + label := post.Created.Format("2006-01") + if _, ok := byMonth[label]; !ok { + months = append(months, label) + byMonth[label] = &Archive{ + Label: label, + Date: time.Date(post.Created.Year(), post.Created.Month(), post.Created.Day(), 0, 0, 0, 0, time.UTC), + Posts: []posts.Post{}, + } + } + byMonth[label].Posts = append(byMonth[label].Posts, post) + } + + // Sort the months. + sort.Sort(sort.Reverse(sort.StringSlice(months))) + + // Prepare the response. + result := []*Archive{} + for _, label := range months { + sort.Sort(sort.Reverse(posts.ByUpdated(byMonth[label].Posts))) + result = append(result, byMonth[label]) + } + + v := map[string]interface{}{ + "Archive": result, + } + render.Template(w, r, "blog/archive", v) +} diff --git a/core/internal/controllers/posts/edit.go b/core/internal/controllers/posts/edit.go new file mode 100644 index 0000000..8ca1b1f --- /dev/null +++ b/core/internal/controllers/posts/edit.go @@ -0,0 +1,110 @@ +package postctl + +import ( + "errors" + "html/template" + "net/http" + "strconv" + "time" + + "github.com/kirsle/blog/core/internal/markdown" + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" + "github.com/kirsle/blog/core/internal/types" +) + +// editHandler is the blog writing and editing page. +func editHandler(w http.ResponseWriter, r *http.Request) { + v := map[string]interface{}{ + "preview": "", + } + var post *posts.Post + + // Are we editing an existing post? + if idStr := r.FormValue("id"); idStr != "" { + id, err := strconv.Atoi(idStr) + if err == nil { + post, err = posts.Load(id) + if err != nil { + v["Error"] = errors.New("that post ID was not found") + post = posts.New() + } + } + } else { + post = posts.New() + } + + if r.Method == http.MethodPost { + // Parse from form values. + post.ParseForm(r) + + // Previewing, or submitting? + switch r.FormValue("submit") { + case "preview": + if post.ContentType == string(types.MARKDOWN) { + v["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) + } else { + v["preview"] = template.HTML(post.Body) + } + case "post": + if err := post.Validate(); err != nil { + v["Error"] = err + } else { + author, _ := auth.CurrentUser(r) + post.AuthorID = author.ID + + post.Updated = time.Now().UTC() + err = post.Save() + if err != nil { + v["Error"] = err + } else { + responses.Flash(w, r, "Post created!") + responses.Redirect(w, "/"+post.Fragment) + } + } + } + } + + v["post"] = post + render.Template(w, r, "blog/edit", v) +} + +// deleteHandler to delete a blog entry. +func deleteHandler(w http.ResponseWriter, r *http.Request) { + var post *posts.Post + v := map[string]interface{}{ + "Post": nil, + } + + var idStr string + if r.Method == http.MethodPost { + idStr = r.FormValue("id") + } else { + idStr = r.URL.Query().Get("id") + } + if idStr == "" { + responses.FlashAndRedirect(w, r, "/admin", "No post ID given for deletion!") + return + } + + // Convert the post ID to an int. + id, err := strconv.Atoi(idStr) + if err == nil { + post, err = posts.Load(id) + if err != nil { + responses.FlashAndRedirect(w, r, "/admin", "That post ID was not found.") + return + } + } + + if r.Method == http.MethodPost { + post.Delete() + responses.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!") + return + } + + v["Post"] = post + render.Template(w, r, "blog/delete", v) +} diff --git a/core/internal/controllers/posts/feeds.go b/core/internal/controllers/posts/feeds.go new file mode 100644 index 0000000..1c9990c --- /dev/null +++ b/core/internal/controllers/posts/feeds.go @@ -0,0 +1,64 @@ +package postctl + +import ( + "net/http" + "strings" + "time" + + "github.com/gorilla/feeds" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/models/settings" + "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/responses" +) + +func feedHandler(w http.ResponseWriter, r *http.Request) { + config, _ := settings.Load() + admin, err := users.Load(1) + if err != nil { + responses.Error(w, r, "Blog isn't ready yet.") + return + } + + feed := &feeds.Feed{ + Title: config.Site.Title, + Link: &feeds.Link{Href: config.Site.URL}, + Description: config.Site.Description, + Author: &feeds.Author{ + Name: admin.Name, + Email: admin.Email, + }, + Created: time.Now(), + } + + feed.Items = []*feeds.Item{} + for i, p := range RecentPosts(r, "", "") { + post, _ := posts.Load(p.ID) + var suffix string + if strings.Contains(post.Body, "") { + post.Body = strings.Split(post.Body, "")[0] + suffix = "..." + } + + feed.Items = append(feed.Items, &feeds.Item{ + Title: p.Title, + Link: &feeds.Link{Href: config.Site.URL + p.Fragment}, + Description: post.Body + suffix, + Created: p.Created, + }) + if i >= 5 { + break + } + } + + // What format to encode it in? + if strings.Contains(r.URL.Path, ".atom") { + atom, _ := feed.ToAtom() + w.Header().Set("Content-Type", "application/atom+xml") + w.Write([]byte(atom)) + } else { + rss, _ := feed.ToRss() + w.Header().Set("Content-Type", "application/rss+xml") + w.Write([]byte(rss)) + } +} diff --git a/core/internal/controllers/posts/index.go b/core/internal/controllers/posts/index.go new file mode 100644 index 0000000..2c0bc64 --- /dev/null +++ b/core/internal/controllers/posts/index.go @@ -0,0 +1,125 @@ +package postctl + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "strconv" + + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/models/comments" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/types" +) + +// partialIndex renders and returns the blog index partial. +func partialIndex(r *http.Request, tag, privacy string) template.HTML { + // Get the recent blog entries, filtered by the tag/privacy settings. + pool := RecentPosts(r, tag, privacy) + if len(pool) == 0 { + return template.HTML("No blog posts were found.") + } + + // Query parameters. + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page <= 0 { + page = 1 + } + perPage := 5 // TODO: configurable + offset := (page - 1) * perPage + stop := offset + perPage + + // Handle pagination. + var previousPage, nextPage int + if page > 1 { + previousPage = page - 1 + } else { + previousPage = 0 + } + if offset+perPage < len(pool) { + nextPage = page + 1 + } else { + nextPage = 0 + } + + var view []PostMeta + for i := offset; i < stop; i++ { + if i >= len(pool) { + continue + } + post, err := posts.Load(pool[i].ID) + if err != nil { + log.Error("couldn't load full post data for ID %d (found in index.json)", pool[i].ID) + continue + } + + // Look up the author's information. + author, err := users.LoadReadonly(post.AuthorID) + if err != nil { + log.Error("Failed to look up post author ID %d (post %d): %v", post.AuthorID, post.ID, err) + 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, + Author: author, + NumComments: numComments, + }) + } + + // Render the blog index partial. + var output bytes.Buffer + v := map[string]interface{}{ + "PreviousPage": previousPage, + "NextPage": nextPage, + "View": view, + } + render.Template(&output, r, "blog/index.partial", v) + + return template.HTML(output.String()) +} + +// indexHandler renders the main index page of the blog. +func indexHandler(w http.ResponseWriter, r *http.Request) { + commonIndexHandler(w, r, "", "") +} + +// drafts renders an index view of only draft posts. Login required. +func drafts(w http.ResponseWriter, r *http.Request) { + commonIndexHandler(w, r, "", types.DRAFT) +} + +// privatePosts renders an index view of only private posts. Login required. +func privatePosts(w http.ResponseWriter, r *http.Request) { + commonIndexHandler(w, r, "", types.PRIVATE) +} + +// commonIndexHandler handles common logic for blog index views. +func commonIndexHandler(w http.ResponseWriter, r *http.Request, tag, privacy string) { + // Page title. + var title string + if privacy == types.DRAFT { + title = "Draft Posts" + } else if privacy == types.PRIVATE { + title = "Private Posts" + } else if tag != "" { + title = "Tagged as: " + tag + } else { + title = "Blog" + } + + render.Template(w, r, "blog/index", map[string]interface{}{ + "Title": title, + "Tag": tag, + "Privacy": privacy, + }) +} diff --git a/core/internal/controllers/posts/posts.go b/core/internal/controllers/posts/posts.go new file mode 100644 index 0000000..5fd0106 --- /dev/null +++ b/core/internal/controllers/posts/posts.go @@ -0,0 +1,212 @@ +package postctl + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "sort" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/log" + "github.com/kirsle/blog/core/internal/markdown" + "github.com/kirsle/blog/core/internal/middleware/auth" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/models/users" + "github.com/kirsle/blog/core/internal/render" + "github.com/kirsle/blog/core/internal/responses" + "github.com/kirsle/blog/core/internal/types" + "github.com/urfave/negroni" +) + +// PostMeta associates a Post with injected metadata. +type PostMeta struct { + Post *posts.Post + Rendered template.HTML + Author *users.User + NumComments int + IndexView bool + Snipped bool +} + +// Archive holds data for a piece of the blog archive. +type Archive struct { + Label string + Date time.Time + Posts []posts.Post +} + +// Register the blog routes to the app. +func Register(r *mux.Router, loginError http.HandlerFunc) { + render.Funcs["RenderIndex"] = partialIndex + render.Funcs["RenderPost"] = partialPost + render.Funcs["RenderTags"] = partialTags + + // Public routes + r.HandleFunc("/blog", indexHandler) + r.HandleFunc("/blog.rss", feedHandler) + r.HandleFunc("/blog.atom", feedHandler) + r.HandleFunc("/archive", archiveHandler) + r.HandleFunc("/tagged", taggedHandler) + r.HandleFunc("/tagged/{tag}", taggedHandler) + r.HandleFunc("/blog/category/{tag}", func(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + tag, ok := params["tag"] + if !ok { + responses.NotFound(w, r, "Not Found") + return + } + responses.Redirect(w, "/tagged/"+tag) + }) + r.HandleFunc("/blog/entry/{fragment}", func(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + fragment, ok := params["fragment"] + if !ok { + responses.NotFound(w, r, "Not Found") + return + } + responses.Redirect(w, "/"+fragment) + }) + + // Login-required routers. + loginRouter := mux.NewRouter() + loginRouter.HandleFunc("/blog/edit", editHandler) + loginRouter.HandleFunc("/blog/delete", deleteHandler) + loginRouter.HandleFunc("/blog/drafts", drafts) + loginRouter.HandleFunc("/blog/private", privatePosts) + r.PathPrefix("/blog").Handler( + negroni.New( + negroni.HandlerFunc(auth.LoginRequired(loginError)), + negroni.Wrap(loginRouter), + ), + ) +} + +// RecentPosts gets and filters the blog entries and orders them by most recent. +func RecentPosts(r *http.Request, tag, privacy string) []posts.Post { + // Get the blog index. + idx, _ := posts.GetIndex() + + // The set of blog posts to show. + var pool []posts.Post + for _, post := range idx.Posts { + // Limiting by a specific privacy setting? (drafts or private only) + if privacy != "" { + switch privacy { + case types.DRAFT: + if post.Privacy != types.DRAFT { + continue + } + case types.PRIVATE: + if post.Privacy != types.PRIVATE && post.Privacy != types.UNLISTED { + continue + } + } + } else { + // Exclude certain posts in generic index views. + if (post.Privacy == types.PRIVATE || post.Privacy == types.UNLISTED) && !auth.LoggedIn(r) { + continue + } else if post.Privacy == types.DRAFT { + continue + } + } + + // Limit by tag? + if tag != "" { + var tagMatch bool + if tag != "" { + for _, check := range post.Tags { + if check == tag { + tagMatch = true + break + } + } + } + + if !tagMatch { + continue + } + } + + pool = append(pool, post) + } + + sort.Sort(sort.Reverse(posts.ByUpdated(pool))) + return pool +} + +// ViewPost is the underlying implementation of the handler to view a blog +// post, so that it can be called from non-http.HandlerFunc contexts. +// Specifically, from the catch-all page handler to allow blog URL fragments +// to map to their post. +func ViewPost(w http.ResponseWriter, r *http.Request, fragment string) error { + post, err := posts.LoadFragment(fragment) + if err != nil { + return err + } + + // Handle post privacy. + if post.Privacy == types.PRIVATE || post.Privacy == types.DRAFT { + if !auth.LoggedIn(r) { + responses.NotFound(w, r, "That post is not public.") + return nil + } + } + + v := map[string]interface{}{ + "Post": post, + } + render.Template(w, r, "blog/entry", v) + + return nil +} + +// partialPost 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 partialPost(r *http.Request, p *posts.Post, indexView bool, numComments int) template.HTML { + // Look up the author's information. + author, err := users.LoadReadonly(p.AuthorID) + if err != nil { + log.Error("Failed to look up post author ID %d (post %d): %v", p.AuthorID, p.ID, err) + author = users.DeletedUser() + } + + // "Read More" snippet for index views. + var snipped bool + if indexView { + if strings.Contains(p.Body, "") { + parts := strings.SplitN(p.Body, "", 2) + p.Body = parts[0] + snipped = true + } + } + + p.Body = strings.Replace(p.Body, "", "
", 1) + + // Render the post to HTML. + var rendered template.HTML + if p.ContentType == string(types.MARKDOWN) { + rendered = template.HTML(markdown.RenderTrustedMarkdown(p.Body)) + } else { + rendered = template.HTML(p.Body) + } + + meta := map[string]interface{}{ + "Post": p, + "Rendered": rendered, + "Author": author, + "IndexView": indexView, + "Snipped": snipped, + "NumComments": numComments, + } + output := bytes.Buffer{} + err = render.Template(&output, r, "blog/entry.partial", meta) + if err != nil { + return template.HTML(fmt.Sprintf("[template error in blog/entry.partial: %s]", err.Error())) + } + + return template.HTML(output.String()) +} diff --git a/core/internal/controllers/posts/tagged.go b/core/internal/controllers/posts/tagged.go new file mode 100644 index 0000000..25d5240 --- /dev/null +++ b/core/internal/controllers/posts/tagged.go @@ -0,0 +1,46 @@ +package postctl + +import ( + "bytes" + "html/template" + "net/http" + + "github.com/gorilla/mux" + "github.com/kirsle/blog/core/internal/models/posts" + "github.com/kirsle/blog/core/internal/render" +) + +// tagged lets you browse blog posts by category. +func taggedHandler(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + tag, ok := params["tag"] + if !ok { + // They're listing all the tags. + render.Template(w, r, "blog/tags.gohtml", nil) + return + } + + commonIndexHandler(w, r, tag, "") +} + +// partialTags renders the tags partial. +func partialTags(r *http.Request, indexView bool) template.HTML { + idx, err := posts.GetIndex() + if err != nil { + return template.HTML("[RenderTags: error getting blog index]") + } + + tags, err := idx.Tags() + if err != nil { + return template.HTML("[RenderTags: error getting tags]") + } + + var output bytes.Buffer + v := map[string]interface{}{ + "IndexView": indexView, + "Tags": tags, + } + render.Template(&output, r, "blog/tags.partial", v) + + return template.HTML(output.String()) +} diff --git a/core/internal/responses/responses.go b/core/internal/responses/responses.go index 8776768..cef2963 100644 --- a/core/internal/responses/responses.go +++ b/core/internal/responses/responses.go @@ -7,6 +7,14 @@ import ( "github.com/kirsle/blog/core/internal/sessions" ) +// Error handlers to be filled in by the blog app. +var ( + NotFound func(http.ResponseWriter, *http.Request, string) + Forbidden func(http.ResponseWriter, *http.Request, string) + BadRequest func(http.ResponseWriter, *http.Request, string) + Error func(http.ResponseWriter, *http.Request, string) +) + // Flash adds a flash message to the user's session. func Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) { session := sessions.Get(r) diff --git a/core/constants.go b/core/internal/types/constants.go similarity index 97% rename from core/constants.go rename to core/internal/types/constants.go index a2bd76e..d1bb841 100644 --- a/core/constants.go +++ b/core/internal/types/constants.go @@ -1,4 +1,4 @@ -package core +package types // PostPrivacy values. type PostPrivacy string diff --git a/core/pages.go b/core/pages.go index 7e0bbbd..d39bce2 100644 --- a/core/pages.go +++ b/core/pages.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" + "github.com/kirsle/blog/core/internal/controllers/posts" "github.com/kirsle/blog/core/internal/markdown" "github.com/kirsle/blog/core/internal/render" "github.com/kirsle/blog/core/internal/responses" @@ -24,7 +25,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Restrict special paths. if strings.HasPrefix(strings.ToLower(path), "/.") { - b.Forbidden(w, r, "Forbidden") + responses.Forbidden(w, r, "Forbidden") return } @@ -32,9 +33,9 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { filepath, err := render.ResolvePath(path) if err != nil { // See if it resolves as a blog entry. - err = b.viewPost(w, r, strings.TrimLeft(path, "/")) + err = postctl.ViewPost(w, r, strings.TrimLeft(path, "/")) if err != nil { - b.NotFound(w, r, "The page you were looking for was not found.") + responses.NotFound(w, r, "The page you were looking for was not found.") } return } @@ -49,7 +50,7 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(filepath.URI, ".md") || strings.HasSuffix(filepath.URI, ".markdown") { source, err := ioutil.ReadFile(filepath.Absolute) if err != nil { - b.Error(w, r, "Couldn't read Markdown source!") + responses.Error(w, r, "Couldn't read Markdown source!") return }