diff --git a/cmd/blog/main.go b/cmd/blog/main.go
index 64aa718..f4de630 100644
--- a/cmd/blog/main.go
+++ b/cmd/blog/main.go
@@ -10,7 +10,7 @@ import (
"os"
"github.com/kirsle/blog/core"
- "github.com/kirsle/blog/core/jsondb"
+ "github.com/kirsle/blog/jsondb"
)
// Build-time config constants.
@@ -51,5 +51,5 @@ func main() {
jsondb.SetDebug(true)
}
- app.ListenAndServe(fAddress)
+ app.Run(fAddress)
}
diff --git a/cmd/rophako-import/main.go b/cmd/rophako-import/main.go
index 78061f6..650832e 100644
--- a/cmd/rophako-import/main.go
+++ b/cmd/rophako-import/main.go
@@ -16,7 +16,7 @@ import (
"time"
"github.com/google/uuid"
- "github.com/kirsle/blog/core/jsondb"
+ "github.com/kirsle/blog/jsondb"
"github.com/kirsle/blog/core/models/comments"
"github.com/kirsle/blog/core/models/posts"
"github.com/kirsle/golog"
diff --git a/core/admin.go b/core/admin.go
deleted file mode 100644
index feda5c9..0000000
--- a/core/admin.go
+++ /dev/null
@@ -1,251 +0,0 @@
-package core
-
-import (
- "fmt"
- "io/ioutil"
- "net/http"
- "net/url"
- "os"
- "path/filepath"
- "strconv"
- "strings"
-
- "github.com/gorilla/mux"
- "github.com/kirsle/blog/core/caches/null"
- "github.com/kirsle/blog/core/caches/redis"
- "github.com/kirsle/blog/core/forms"
- "github.com/kirsle/blog/core/models/settings"
- "github.com/urfave/negroni"
-)
-
-// AdminRoutes attaches the admin routes to the app.
-func (b *Blog) AdminRoutes(r *mux.Router) {
- adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(true)
- adminRouter.HandleFunc("/", b.AdminHandler)
- adminRouter.HandleFunc("/settings", b.SettingsHandler)
- adminRouter.HandleFunc("/editor", b.EditorHandler)
- // r.HandleFunc("/admin", b.AdminHandler)
- r.PathPrefix("/admin").Handler(negroni.New(
- negroni.HandlerFunc(b.LoginRequired),
- negroni.Wrap(adminRouter),
- ))
-}
-
-// AdminHandler is the admin landing page.
-func (b *Blog) AdminHandler(w http.ResponseWriter, r *http.Request) {
- b.RenderTemplate(w, r, "admin/index", nil)
-}
-
-// FileTree holds information about files in the document roots.
-type FileTree struct {
- UserRoot bool // false = CoreRoot
- Files []Filepath
-}
-
-// EditorHandler lets you edit web pages from the frontend.
-func (b *Blog) EditorHandler(w http.ResponseWriter, r *http.Request) {
- // Editing a page?
- file := strings.Trim(r.FormValue("file"), "/")
- if len(file) > 0 {
- var (
- fp string
- fromCore = r.FormValue("from") == "core"
- saving = r.FormValue("action") == "save"
- deleting = r.FormValue("action") == "delete"
- body = []byte{}
- )
-
- // Are they saving?
- if saving {
- fp = filepath.Join(b.UserRoot, file)
- body = []byte(r.FormValue("body"))
- err := ioutil.WriteFile(fp, body, 0644)
- if err != nil {
- b.Flash(w, r, "Error saving: %s", err)
- } else {
- b.FlashAndRedirect(w, r, "/admin/editor?file="+url.QueryEscape(file), "Page saved successfully!")
- return
- }
- } else if deleting {
- fp = filepath.Join(b.UserRoot, file)
- err := os.Remove(fp)
- if err != nil {
- b.FlashAndRedirect(w, r, "/admin/editor", "Error deleting: %s", err)
- } else {
- b.FlashAndRedirect(w, r, "/admin/editor", "Page deleted!")
- return
- }
- } else {
- // Where is the file from?
- if fromCore {
- fp = filepath.Join(b.DocumentRoot, file)
- } else {
- fp = filepath.Join(b.UserRoot, file)
- }
-
- // Check the file. If not found, check from the core root.
- f, err := os.Stat(fp)
- if os.IsNotExist(err) {
- fp = filepath.Join(b.DocumentRoot, file)
- fromCore = true
- f, err = os.Stat(fp)
- }
-
- // If it exists, load it.
- if !os.IsNotExist(err) && !f.IsDir() {
- body, err = ioutil.ReadFile(fp)
- if err != nil {
- b.Flash(w, r, "Error reading %s: %s", fp, err)
- }
- }
-
- // Default HTML boilerplate for .gohtml templates.
- if len(body) == 0 && strings.HasSuffix(fp, ".gohtml") {
- body = []byte("{{ define \"title\" }}Untitled Page{{ end }}\n" +
- "{{ define \"content\" }}\n
Untitled Page
\n\n{{ end }}")
- }
- }
-
- v := NewVars(map[interface{}]interface{}{
- "File": file,
- "Path": fp,
- "Body": string(body),
- "FromCore": fromCore,
- })
- b.RenderTemplate(w, r, "admin/editor", v)
- return
- }
-
- // Otherwise listing the index view.
- b.editorFileList(w, r)
-}
-
-// editorFileList handles the index view of /admin/editor.
-func (b *Blog) editorFileList(w http.ResponseWriter, r *http.Request) {
- // Listing the file tree?
- trees := []FileTree{}
- for i, root := range []string{b.UserRoot, b.DocumentRoot} {
- tree := FileTree{
- UserRoot: i == 0,
- Files: []Filepath{},
- }
-
- filepath.Walk(root, func(path string, f os.FileInfo, err error) error {
- abs, _ := filepath.Abs(path)
- rel, _ := filepath.Rel(root, path)
-
- // Skip hidden files and directories.
- if f.IsDir() || rel == "." || strings.HasPrefix(rel, ".private") || strings.HasPrefix(rel, "admin/") {
- return nil
- }
-
- // Only text files.
- ext := strings.ToLower(filepath.Ext(path))
- okTypes := []string{
- ".html", ".gohtml", ".md", ".markdown", ".js", ".css", ".jsx",
- }
- ok := false
- for _, ft := range okTypes {
- if ext == ft {
- ok = true
- break
- }
- }
- if !ok {
- return nil
- }
-
- tree.Files = append(tree.Files, Filepath{
- Absolute: abs,
- Relative: rel,
- Basename: filepath.Base(path),
- })
- return nil
- })
-
- trees = append(trees, tree)
- }
- v := NewVars(map[interface{}]interface{}{
- "FileTrees": trees,
- })
- b.RenderTemplate(w, r, "admin/filelist", v)
-}
-
-// SettingsHandler lets you configure the app from the frontend.
-func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) {
- v := NewVars()
-
- // Get the current settings.
- settings, _ := settings.Load()
- v.Data["s"] = settings
-
- if r.Method == http.MethodPost {
- redisPort, _ := strconv.Atoi(r.FormValue("redis-port"))
- redisDB, _ := strconv.Atoi(r.FormValue("redis-db"))
- mailPort, _ := strconv.Atoi(r.FormValue("mail-port"))
- form := &forms.Settings{
- Title: r.FormValue("title"),
- Description: r.FormValue("description"),
- AdminEmail: r.FormValue("admin-email"),
- URL: r.FormValue("url"),
- RedisEnabled: len(r.FormValue("redis-enabled")) > 0,
- RedisHost: r.FormValue("redis-host"),
- RedisPort: redisPort,
- RedisDB: redisDB,
- RedisPrefix: r.FormValue("redis-prefix"),
- MailEnabled: len(r.FormValue("mail-enabled")) > 0,
- MailSender: r.FormValue("mail-sender"),
- MailHost: r.FormValue("mail-host"),
- MailPort: mailPort,
- MailUsername: r.FormValue("mail-username"),
- MailPassword: r.FormValue("mail-password"),
- }
-
- // Copy form values into the settings struct for display, in case of
- // any validation errors.
- settings.Site.Title = form.Title
- settings.Site.Description = form.Description
- settings.Site.AdminEmail = form.AdminEmail
- settings.Site.URL = form.URL
- settings.Redis.Enabled = form.RedisEnabled
- settings.Redis.Host = form.RedisHost
- settings.Redis.Port = form.RedisPort
- settings.Redis.DB = form.RedisDB
- settings.Redis.Prefix = form.RedisPrefix
- settings.Mail.Enabled = form.MailEnabled
- settings.Mail.Sender = form.MailSender
- settings.Mail.Host = form.MailHost
- settings.Mail.Port = form.MailPort
- settings.Mail.Username = form.MailUsername
- settings.Mail.Password = form.MailPassword
- err := form.Validate()
- if err != nil {
- v.Error = err
- } else {
- // Save the settings.
- settings.Save()
-
- // Reset Redis configuration.
- if settings.Redis.Enabled {
- cache, err := redis.New(
- fmt.Sprintf("%s:%d", settings.Redis.Host, settings.Redis.Port),
- settings.Redis.DB,
- settings.Redis.Prefix,
- )
- if err != nil {
- b.Flash(w, r, "Error connecting to Redis: %s", err)
- b.Cache = null.New()
- } else {
- b.Cache = cache
- }
- } else {
- b.Cache = null.New()
- }
- b.DB.Cache = b.Cache
-
- b.FlashAndReload(w, r, "Settings have been saved!")
- return
- }
- }
- b.RenderTemplate(w, r, "admin/settings", v)
-}
diff --git a/core/app.go b/core/app.go
deleted file mode 100644
index f8f7386..0000000
--- a/core/app.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package core
-
-import (
- "fmt"
- "net/http"
- "path/filepath"
-
- "github.com/gorilla/mux"
- "github.com/gorilla/sessions"
- "github.com/kirsle/blog/core/caches"
- "github.com/kirsle/blog/core/caches/null"
- "github.com/kirsle/blog/core/caches/redis"
- "github.com/kirsle/blog/core/jsondb"
- "github.com/kirsle/blog/core/models/comments"
- "github.com/kirsle/blog/core/models/posts"
- "github.com/kirsle/blog/core/models/settings"
- "github.com/kirsle/blog/core/models/users"
- "github.com/shurcooL/github_flavored_markdown/gfmstyle"
- "github.com/urfave/negroni"
-)
-
-// Blog is the root application object that maintains the app configuration
-// and helper objects.
-type Blog struct {
- Debug bool
-
- // DocumentRoot is the core static files root; UserRoot masks over it.
- DocumentRoot string
- UserRoot string
-
- DB *jsondb.DB
- Cache caches.Cacher
-
- // Web app objects.
- n *negroni.Negroni // Negroni middleware manager
- r *mux.Router // Router
- store sessions.Store
-}
-
-// New initializes the Blog application.
-func New(documentRoot, userRoot string) *Blog {
- blog := &Blog{
- DocumentRoot: documentRoot,
- UserRoot: userRoot,
- DB: jsondb.New(filepath.Join(userRoot, ".private")),
- Cache: null.New(),
- }
-
- // Load the site config, or start with defaults if not found.
- settings.DB = blog.DB
- config, err := settings.Load()
- if err != nil {
- config = settings.Defaults()
- }
-
- // Initialize the session cookie store.
- blog.store = sessions.NewCookieStore([]byte(config.Security.SecretKey))
- users.HashCost = config.Security.HashCost
-
- // Initialize the rest of the models.
- posts.DB = blog.DB
- users.DB = blog.DB
- comments.DB = blog.DB
-
- // Redis cache?
- if config.Redis.Enabled {
- addr := fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port)
- log.Info("Connecting to Redis at %s/%d", addr, config.Redis.DB)
- cache, err := redis.New(
- addr,
- config.Redis.DB,
- config.Redis.Prefix,
- )
- if err != nil {
- log.Error("Redis init error: %s", err.Error())
- } else {
- blog.Cache = cache
- blog.DB.Cache = cache
- }
- }
-
- // Initialize the router.
- r := mux.NewRouter()
- r.HandleFunc("/initial-setup", blog.SetupHandler)
- blog.AuthRoutes(r)
- blog.AdminRoutes(r)
- blog.ContactRoutes(r)
- blog.BlogRoutes(r)
- blog.CommentRoutes(r)
-
- // GitHub Flavored Markdown CSS.
- r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))
-
- r.PathPrefix("/").HandlerFunc(blog.PageHandler)
- r.NotFoundHandler = http.HandlerFunc(blog.PageHandler)
-
- n := negroni.New(
- negroni.NewRecovery(),
- negroni.NewLogger(),
- negroni.HandlerFunc(blog.SessionLoader),
- negroni.HandlerFunc(blog.CSRFMiddleware),
- negroni.HandlerFunc(blog.AuthMiddleware),
- )
- n.UseHandler(r)
-
- // Keep references handy elsewhere in the app.
- blog.n = n
- blog.r = r
-
- return blog
-}
-
-// ListenAndServe begins listening on the given bind address.
-func (b *Blog) ListenAndServe(address string) {
- log.Info("Listening on %s", address)
- http.ListenAndServe(address, b.n)
-}
diff --git a/core/blog.go b/core/blog.go
deleted file mode 100644
index 94f2400..0000000
--- a/core/blog.go
+++ /dev/null
@@ -1,556 +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/models/comments"
- "github.com/kirsle/blog/core/models/posts"
- "github.com/kirsle/blog/core/models/settings"
- "github.com/kirsle/blog/core/models/users"
- "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) {
- // 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
- }
- b.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
- }
- b.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(b.LoginRequired),
- negroni.Wrap(loginRouter),
- ),
- )
-
- adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(false)
- r.HandleFunc("/admin", b.AdminHandler) // so as to not be "/admin/"
- adminRouter.HandleFunc("/settings", b.SettingsHandler)
- adminRouter.PathPrefix("/").HandlerFunc(b.PageHandler)
- r.PathPrefix("/admin").Handler(negroni.New(
- negroni.HandlerFunc(b.LoginRequired),
- negroni.Wrap(adminRouter),
- ))
-}
-
-// 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.
- b.RenderTemplate(w, r, "blog/tags.gohtml", NewVars())
- 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"
- }
-
- b.RenderTemplate(w, r, "blog/index", NewVars(map[interface{}]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) && !b.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,
- }
- b.RenderPartialTemplate(&output, "blog/index.partial", v, false, nil)
-
- 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 := struct {
- IndexView bool
- Tags []posts.Tag
- }{
- IndexView: indexView,
- Tags: tags,
- }
- b.RenderPartialTemplate(&output, "blog/tags.partial", v, false, nil)
-
- 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) && !b.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 := NewVars(map[interface{}]interface{}{
- "Archive": result,
- })
- b.RenderTemplate(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 !b.LoggedIn(r) {
- b.NotFound(w, r)
- return nil
- }
- }
-
- v := NewVars(map[interface{}]interface{}{
- "Post": post,
- })
- b.RenderTemplate(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(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(b.RenderTrustedMarkdown(p.Body))
- } else {
- rendered = template.HTML(p.Body)
- }
-
- meta := PostMeta{
- Post: p,
- Rendered: rendered,
- Author: author,
- IndexView: indexView,
- Snipped: snipped,
- NumComments: numComments,
- }
- output := bytes.Buffer{}
- err = b.RenderPartialTemplate(&output, "blog/entry.partial", meta, false, nil)
- 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 := NewVars(map[interface{}]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.Data["preview"] = template.HTML(b.RenderTrustedMarkdown(post.Body))
- } else {
- v.Data["preview"] = template.HTML(post.Body)
- }
- case "post":
- if err := post.Validate(); err != nil {
- v.Error = err
- } else {
- author, _ := b.CurrentUser(r)
- post.AuthorID = author.ID
-
- post.Updated = time.Now().UTC()
- err = post.Save()
- if err != nil {
- v.Error = err
- } else {
- b.Flash(w, r, "Post created!")
- b.Redirect(w, "/"+post.Fragment)
- }
- }
- }
- }
-
- v.Data["post"] = post
- b.RenderTemplate(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 := NewVars(map[interface{}]interface{}{
- "Post": nil,
- })
-
- var idStr string
- if r.Method == http.MethodPost {
- idStr = r.FormValue("id")
- } else {
- idStr = r.URL.Query().Get("id")
- }
- if idStr == "" {
- b.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 {
- b.FlashAndRedirect(w, r, "/admin", "That post ID was not found.")
- return
- }
- }
-
- if r.Method == http.MethodPost {
- post.Delete()
- b.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!")
- return
- }
-
- v.Data["Post"] = post
- b.RenderTemplate(w, r, "blog/delete", v)
-}
diff --git a/core/core.go b/core/core.go
new file mode 100644
index 0000000..5081fac
--- /dev/null
+++ b/core/core.go
@@ -0,0 +1,154 @@
+// Package core implements the core source code of kirsle/blog.
+package core
+
+import (
+ "fmt"
+ "net/http"
+ "path/filepath"
+
+ "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"
+ "github.com/kirsle/blog/core/internal/middleware"
+ "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/kirsle/blog/core/internal/sessions"
+ "github.com/kirsle/blog/jsondb"
+ "github.com/kirsle/blog/jsondb/caches"
+ "github.com/kirsle/blog/jsondb/caches/null"
+ "github.com/kirsle/blog/jsondb/caches/redis"
+ "github.com/shurcooL/github_flavored_markdown/gfmstyle"
+ "github.com/urfave/negroni"
+)
+
+// Blog is the root application object that maintains the app configuration
+// and helper objects.
+type Blog struct {
+ Debug bool
+
+ // DocumentRoot is the core static files root; UserRoot masks over it.
+ DocumentRoot string
+ UserRoot string
+
+ DB *jsondb.DB
+ Cache caches.Cacher
+
+ // Web app objects.
+ n *negroni.Negroni // Negroni middleware manager
+ r *mux.Router // Router
+}
+
+// New initializes the Blog application.
+func New(documentRoot, userRoot string) *Blog {
+ return &Blog{
+ DocumentRoot: documentRoot,
+ UserRoot: userRoot,
+ DB: jsondb.New(filepath.Join(userRoot, ".private")),
+ Cache: null.New(),
+ }
+}
+
+// Run quickly configures and starts the HTTP server.
+func (b *Blog) Run(address string) {
+ b.Configure()
+ b.SetupHTTP()
+ b.ListenAndServe(address)
+}
+
+// Configure initializes (or reloads) the blog's configuration, and binds the
+// settings in sub-packages.
+func (b *Blog) Configure() {
+ // Load the site config, or start with defaults if not found.
+ settings.DB = b.DB
+ config, err := settings.Load()
+ if err != nil {
+ config = settings.Defaults()
+ }
+
+ // Bind configs in sub-packages.
+ render.UserRoot = &b.UserRoot
+ render.DocumentRoot = &b.DocumentRoot
+
+ // Initialize the session cookie store.
+ sessions.SetSecretKey([]byte(config.Security.SecretKey))
+ users.HashCost = config.Security.HashCost
+
+ // Initialize the rest of the models.
+ posts.DB = b.DB
+ users.DB = b.DB
+ comments.DB = b.DB
+
+ // Redis cache?
+ if config.Redis.Enabled {
+ addr := fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port)
+ log.Info("Connecting to Redis at %s/%d", addr, config.Redis.DB)
+ cache, err := redis.New(
+ addr,
+ config.Redis.DB,
+ config.Redis.Prefix,
+ )
+ if err != nil {
+ log.Error("Redis init error: %s", err.Error())
+ } else {
+ b.Cache = cache
+ b.DB.Cache = cache
+ markdown.Cache = cache
+ }
+ }
+
+ b.registerErrors()
+}
+
+// SetupHTTP initializes the Negroni middleware engine and registers routes.
+func (b *Blog) SetupHTTP() {
+ // Initialize the router.
+ r := mux.NewRouter()
+ setup.Register(r)
+ authctl.Register(r)
+ admin.Register(r, b.MustLogin)
+ 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)))
+
+ r.PathPrefix("/").HandlerFunc(b.PageHandler)
+ r.NotFoundHandler = http.HandlerFunc(b.PageHandler)
+
+ n := negroni.New(
+ negroni.NewRecovery(),
+ negroni.NewLogger(),
+ negroni.HandlerFunc(sessions.Middleware),
+ negroni.HandlerFunc(middleware.CSRF(responses.Forbidden)),
+ negroni.HandlerFunc(auth.Middleware),
+ )
+ n.UseHandler(r)
+
+ // Keep references handy elsewhere in the app.
+ b.n = n
+ b.r = r
+}
+
+// ListenAndServe begins listening on the given bind address.
+func (b *Blog) ListenAndServe(address string) {
+ log.Info("Listening on %s", address)
+ http.ListenAndServe(address, b.n)
+}
+
+// MustLogin handles errors from the LoginRequired middleware by redirecting
+// the user to the login page.
+func (b *Blog) MustLogin(w http.ResponseWriter, r *http.Request) {
+ responses.Redirect(w, "/login?next="+r.URL.Path)
+}
diff --git a/core/errors.go b/core/errors.go
new file mode 100644
index 0000000..52175cf
--- /dev/null
+++ b/core/errors.go
@@ -0,0 +1,61 @@
+package core
+
+import (
+ "net/http"
+
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/render"
+ "github.com/kirsle/blog/core/internal/responses"
+)
+
+// 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()"))
+ }
+ }
+
+ 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()"))
+ }
+ }
+
+ 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()"))
+ }
+ }
+
+ 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()"))
+ }
+ }
+
+}
diff --git a/core/initial-setup.go b/core/initial-setup.go
deleted file mode 100644
index ee88843..0000000
--- a/core/initial-setup.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package core
-
-import (
- "net/http"
-
- "github.com/gorilla/sessions"
- "github.com/kirsle/blog/core/forms"
- "github.com/kirsle/blog/core/models/settings"
- "github.com/kirsle/blog/core/models/users"
-)
-
-// SetupHandler is the initial blog setup route.
-func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) {
- vars := &Vars{
- Form: forms.Setup{},
- }
-
- // Reject if we're already set up.
- s, _ := settings.Load()
- if s.Initialized {
- b.FlashAndRedirect(w, r, "/", "This website has already been configured.")
- return
- }
-
- if r.Method == http.MethodPost {
- form := forms.Setup{
- Username: r.FormValue("username"),
- Password: r.FormValue("password"),
- Confirm: r.FormValue("confirm"),
- }
- vars.Form = form
- err := form.Validate()
- if err != nil {
- vars.Error = err
- } else {
- // Save the site config.
- log.Info("Creating default website config file")
- s := settings.Defaults()
- s.Save()
-
- // Re-initialize the cookie store with the new secret key.
- b.store = sessions.NewCookieStore([]byte(s.Security.SecretKey))
-
- log.Info("Creating admin account %s", form.Username)
- user := &users.User{
- Username: form.Username,
- Password: form.Password,
- Admin: true,
- Name: "Administrator",
- }
- err := users.Create(user)
- if err != nil {
- log.Error("Error: %v", err)
- vars.Error = err
- }
-
- // All set!
- b.Login(w, r, user)
- b.FlashAndRedirect(w, r, "/admin", "Admin user created and logged in.")
- return
- }
- }
-
- b.RenderTemplate(w, r, "initial-setup", vars)
-}
diff --git a/core/internal/controllers/admin/admin.go b/core/internal/controllers/admin/admin.go
new file mode 100644
index 0000000..1ade73c
--- /dev/null
+++ b/core/internal/controllers/admin/admin.go
@@ -0,0 +1,27 @@
+package admin
+
+import (
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/kirsle/blog/core/internal/middleware/auth"
+ "github.com/kirsle/blog/core/internal/render"
+ "github.com/urfave/negroni"
+)
+
+// Register the initial setup routes.
+func Register(r *mux.Router, authErrorFunc http.HandlerFunc) {
+ adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(true)
+ adminRouter.HandleFunc("/", indexHandler)
+ adminRouter.HandleFunc("/settings", settingsHandler)
+ adminRouter.HandleFunc("/editor", editorHandler)
+
+ r.PathPrefix("/admin").Handler(negroni.New(
+ negroni.HandlerFunc(auth.LoginRequired(authErrorFunc)),
+ negroni.Wrap(adminRouter),
+ ))
+}
+
+func indexHandler(w http.ResponseWriter, r *http.Request) {
+ render.Template(w, r, "admin/index", nil)
+}
diff --git a/core/internal/controllers/admin/editor.go b/core/internal/controllers/admin/editor.go
new file mode 100644
index 0000000..448bde2
--- /dev/null
+++ b/core/internal/controllers/admin/editor.go
@@ -0,0 +1,147 @@
+package admin
+
+import (
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/kirsle/blog/core/internal/render"
+ "github.com/kirsle/blog/core/internal/responses"
+)
+
+// FileTree holds information about files in the document roots.
+type FileTree struct {
+ UserRoot bool // false = CoreRoot
+ Files []render.Filepath
+}
+
+func editorHandler(w http.ResponseWriter, r *http.Request) {
+ // Editing a page?
+ file := strings.Trim(r.FormValue("file"), "/")
+ if len(file) > 0 {
+ var (
+ fp string
+ fromCore = r.FormValue("from") == "core"
+ saving = r.FormValue("action") == "save"
+ deleting = r.FormValue("action") == "delete"
+ body = []byte{}
+ )
+
+ // Are they saving?
+ if saving {
+ fp = filepath.Join(*render.UserRoot, file)
+ body = []byte(r.FormValue("body"))
+ err := ioutil.WriteFile(fp, body, 0644)
+ if err != nil {
+ responses.Flash(w, r, "Error saving: %s", err)
+ } else {
+ responses.FlashAndRedirect(w, r, "/admin/editor?file="+url.QueryEscape(file), "Page saved successfully!")
+ return
+ }
+ } else if deleting {
+ fp = filepath.Join(*render.UserRoot, file)
+ err := os.Remove(fp)
+ if err != nil {
+ responses.FlashAndRedirect(w, r, "/admin/editor", "Error deleting: %s", err)
+ } else {
+ responses.FlashAndRedirect(w, r, "/admin/editor", "Page deleted!")
+ return
+ }
+ } else {
+ // Where is the file from?
+ if fromCore {
+ fp = filepath.Join(*render.DocumentRoot, file)
+ } else {
+ fp = filepath.Join(*render.UserRoot, file)
+ }
+
+ // Check the file. If not found, check from the core root.
+ f, err := os.Stat(fp)
+ if os.IsNotExist(err) {
+ fp = filepath.Join(*render.DocumentRoot, file)
+ fromCore = true
+ f, err = os.Stat(fp)
+ }
+
+ // If it exists, load it.
+ if !os.IsNotExist(err) && !f.IsDir() {
+ body, err = ioutil.ReadFile(fp)
+ if err != nil {
+ responses.Flash(w, r, "Error reading %s: %s", fp, err)
+ }
+ }
+
+ // Default HTML boilerplate for .gohtml templates.
+ if len(body) == 0 && strings.HasSuffix(fp, ".gohtml") {
+ body = []byte("{{ define \"title\" }}Untitled Page{{ end }}\n" +
+ "{{ define \"content\" }}\nUntitled Page
\n\n{{ end }}")
+ }
+ }
+
+ v := map[string]interface{}{
+ "File": file,
+ "Path": fp,
+ "Body": string(body),
+ "FromCore": fromCore,
+ }
+ render.Template(w, r, "admin/editor", v)
+ return
+ }
+
+ // Otherwise listing the index view.
+ editorFileList(w, r)
+}
+
+// editorFileList handles the index view of /admin/editor.
+func editorFileList(w http.ResponseWriter, r *http.Request) {
+ // Listing the file tree?
+ trees := []FileTree{}
+ for i, root := range []string{*render.UserRoot, *render.DocumentRoot} {
+ tree := FileTree{
+ UserRoot: i == 0,
+ Files: []render.Filepath{},
+ }
+
+ filepath.Walk(root, func(path string, f os.FileInfo, err error) error {
+ abs, _ := filepath.Abs(path)
+ rel, _ := filepath.Rel(root, path)
+
+ // Skip hidden files and directories.
+ if f.IsDir() || rel == "." || strings.HasPrefix(rel, ".private") || strings.HasPrefix(rel, "admin/") {
+ return nil
+ }
+
+ // Only text files.
+ ext := strings.ToLower(filepath.Ext(path))
+ okTypes := []string{
+ ".html", ".gohtml", ".md", ".markdown", ".js", ".css", ".jsx",
+ }
+ ok := false
+ for _, ft := range okTypes {
+ if ext == ft {
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ return nil
+ }
+
+ tree.Files = append(tree.Files, render.Filepath{
+ Absolute: abs,
+ Relative: rel,
+ Basename: filepath.Base(path),
+ })
+ return nil
+ })
+
+ trees = append(trees, tree)
+ }
+ v := map[string]interface{}{
+ "FileTrees": trees,
+ }
+ render.Template(w, r, "admin/filelist", v)
+}
diff --git a/core/internal/controllers/admin/settings.go b/core/internal/controllers/admin/settings.go
new file mode 100644
index 0000000..be66074
--- /dev/null
+++ b/core/internal/controllers/admin/settings.go
@@ -0,0 +1,72 @@
+package admin
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/kirsle/blog/core/internal/forms"
+ "github.com/kirsle/blog/core/internal/models/settings"
+ "github.com/kirsle/blog/core/internal/render"
+ "github.com/kirsle/blog/core/internal/responses"
+)
+
+func settingsHandler(w http.ResponseWriter, r *http.Request) {
+ // Get the current settings.
+ settings, _ := settings.Load()
+ v := map[string]interface{}{
+ "s": settings,
+ }
+
+ if r.Method == http.MethodPost {
+ redisPort, _ := strconv.Atoi(r.FormValue("redis-port"))
+ redisDB, _ := strconv.Atoi(r.FormValue("redis-db"))
+ mailPort, _ := strconv.Atoi(r.FormValue("mail-port"))
+ form := &forms.Settings{
+ Title: r.FormValue("title"),
+ Description: r.FormValue("description"),
+ AdminEmail: r.FormValue("admin-email"),
+ URL: r.FormValue("url"),
+ RedisEnabled: len(r.FormValue("redis-enabled")) > 0,
+ RedisHost: r.FormValue("redis-host"),
+ RedisPort: redisPort,
+ RedisDB: redisDB,
+ RedisPrefix: r.FormValue("redis-prefix"),
+ MailEnabled: len(r.FormValue("mail-enabled")) > 0,
+ MailSender: r.FormValue("mail-sender"),
+ MailHost: r.FormValue("mail-host"),
+ MailPort: mailPort,
+ MailUsername: r.FormValue("mail-username"),
+ MailPassword: r.FormValue("mail-password"),
+ }
+
+ // Copy form values into the settings struct for display, in case of
+ // any validation errors.
+ settings.Site.Title = form.Title
+ settings.Site.Description = form.Description
+ settings.Site.AdminEmail = form.AdminEmail
+ settings.Site.URL = form.URL
+ settings.Redis.Enabled = form.RedisEnabled
+ settings.Redis.Host = form.RedisHost
+ settings.Redis.Port = form.RedisPort
+ settings.Redis.DB = form.RedisDB
+ settings.Redis.Prefix = form.RedisPrefix
+ settings.Mail.Enabled = form.MailEnabled
+ settings.Mail.Sender = form.MailSender
+ settings.Mail.Host = form.MailHost
+ settings.Mail.Port = form.MailPort
+ settings.Mail.Username = form.MailUsername
+ settings.Mail.Password = form.MailPassword
+ err := form.Validate()
+ if err != nil {
+ v["Error"] = err
+ } else {
+ // Save the settings.
+ settings.Save()
+ // b.Configure()
+
+ responses.FlashAndReload(w, r, "Settings have been saved!")
+ return
+ }
+ }
+ render.Template(w, r, "admin/settings", v)
+}
diff --git a/core/auth.go b/core/internal/controllers/authctl/authctl.go
similarity index 50%
rename from core/auth.go
rename to core/internal/controllers/authctl/authctl.go
index 4ac67e1..42da874 100644
--- a/core/auth.go
+++ b/core/internal/controllers/authctl/authctl.go
@@ -1,37 +1,30 @@
-package core
+package authctl
import (
"errors"
"net/http"
"github.com/gorilla/mux"
- "github.com/kirsle/blog/core/forms"
- "github.com/kirsle/blog/core/models/users"
+ "github.com/kirsle/blog/core/internal/forms"
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/middleware/auth"
+ "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/sessions"
)
-// AuthRoutes attaches the auth routes to the app.
-func (b *Blog) AuthRoutes(r *mux.Router) {
- r.HandleFunc("/login", b.LoginHandler)
- r.HandleFunc("/logout", b.LogoutHandler)
- r.HandleFunc("/account", b.AccountHandler)
+// Register the initial setup routes.
+func Register(r *mux.Router) {
+ r.HandleFunc("/login", loginHandler)
+ r.HandleFunc("/logout", logoutHandler)
+ r.HandleFunc("/account", accountHandler)
}
-// Login logs the browser in as the given user.
-func (b *Blog) Login(w http.ResponseWriter, r *http.Request, u *users.User) error {
- session, err := b.store.Get(r, "session") // TODO session name
- if err != nil {
- return err
+func loginHandler(w http.ResponseWriter, r *http.Request) {
+ vars := map[string]interface{}{
+ "Form": forms.Setup{},
}
- session.Values["logged-in"] = true
- session.Values["user-id"] = u.ID
- session.Save(r, w)
- return nil
-}
-
-// LoginHandler shows and handles the login page.
-func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) {
- vars := NewVars()
- vars.Form = forms.Setup{}
var nextURL string
if r.Method == http.MethodPost {
@@ -39,77 +32,76 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) {
} else {
nextURL = r.URL.Query().Get("next")
}
- vars.Data["NextURL"] = nextURL
+ vars["NextURL"] = nextURL
if r.Method == http.MethodPost {
form := &forms.Login{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
}
- vars.Form = form
+ vars["Form"] = form
err := form.Validate()
if err != nil {
- vars.Error = err
+ vars["Error"] = err
} else {
// Test the login.
user, err := users.CheckAuth(form.Username, form.Password)
if err != nil {
- vars.Error = errors.New("bad username or password")
+ vars["Error"] = errors.New("bad username or password")
} else {
// Login OK!
- b.Flash(w, r, "Login OK!")
- b.Login(w, r, user)
+ responses.Flash(w, r, "Login OK!")
+ auth.Login(w, r, user)
// A next URL given? TODO: actually get to work
log.Info("Redirect after login to: %s", nextURL)
if len(nextURL) > 0 && nextURL[0] == '/' {
- b.Redirect(w, nextURL)
+ responses.Redirect(w, nextURL)
} else {
- b.Redirect(w, "/")
+ responses.Redirect(w, "/")
}
return
}
}
}
- b.RenderTemplate(w, r, "login", vars)
+ render.Template(w, r, "login", vars)
}
-// LogoutHandler logs the user out and redirects to the home page.
-func (b *Blog) LogoutHandler(w http.ResponseWriter, r *http.Request) {
- session, _ := b.store.Get(r, "session")
+func logoutHandler(w http.ResponseWriter, r *http.Request) {
+ session, _ := sessions.Store.Get(r, "session")
delete(session.Values, "logged-in")
delete(session.Values, "user-id")
session.Save(r, w)
- b.Redirect(w, "/")
+ responses.Redirect(w, "/")
}
-// AccountHandler shows the account settings page.
-func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) {
- if !b.LoggedIn(r) {
- b.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!")
+func accountHandler(w http.ResponseWriter, r *http.Request) {
+ if !auth.LoggedIn(r) {
+ responses.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!")
return
}
- currentUser, err := b.CurrentUser(r)
+ currentUser, err := auth.CurrentUser(r)
if err != nil {
- b.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!!")
+ responses.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!!")
return
}
// Load an editable copy of the user.
user, err := users.Load(currentUser.ID)
if err != nil {
- b.FlashAndRedirect(w, r, "/login?next=/account", "User ID %d not loadable?", currentUser.ID)
+ responses.FlashAndRedirect(w, r, "/login?next=/account", "User ID %d not loadable?", currentUser.ID)
return
}
- v := NewVars()
form := &forms.Account{
Username: user.Username,
Email: user.Email,
Name: user.Name,
}
- v.Form = form
+ v := map[string]interface{}{
+ "Form": form,
+ }
if r.Method == http.MethodPost {
form.Username = users.Normalize(r.FormValue("username"))
@@ -119,14 +111,14 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) {
form.NewPassword = r.FormValue("newpassword")
form.NewPassword2 = r.FormValue("newpassword2")
if err = form.Validate(); err != nil {
- b.Flash(w, r, err.Error())
+ responses.Flash(w, r, err.Error())
} else {
var ok = true
// Validate the username is available.
if form.Username != user.Username {
if _, err = users.LoadUsername(form.Username); err == nil {
- b.Flash(w, r, "That username already exists.")
+ responses.Flash(w, r, "That username already exists.")
ok = false
}
}
@@ -135,12 +127,12 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) {
if len(form.OldPassword) > 0 {
// Validate their old password.
if _, err = users.CheckAuth(form.Username, form.OldPassword); err != nil {
- b.Flash(w, r, "Your old password is incorrect.")
+ responses.Flash(w, r, "Your old password is incorrect.")
ok = false
} else {
err = user.SetPassword(form.NewPassword)
if err != nil {
- b.Flash(w, r, "Change password error: %s", err)
+ responses.Flash(w, r, "Change password error: %s", err)
ok = false
}
}
@@ -153,14 +145,14 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) {
user.Email = form.Email
err = user.Save()
if err != nil {
- b.Flash(w, r, "Error saving user: %s", err)
+ responses.Flash(w, r, "Error saving user: %s", err)
} else {
- b.FlashAndRedirect(w, r, "/account", "Settings saved!")
+ responses.FlashAndRedirect(w, r, "/account", "Settings saved!")
return
}
}
}
}
- b.RenderTemplate(w, r, "account", v)
+ render.Template(w, r, "account", v)
}
diff --git a/core/comments.go b/core/internal/controllers/comments/comments.go
similarity index 62%
rename from core/comments.go
rename to core/internal/controllers/comments/comments.go
index 710c87d..8ee594a 100644
--- a/core/comments.go
+++ b/core/internal/controllers/comments/comments.go
@@ -1,25 +1,34 @@
-package core
+package comments
import (
"bytes"
- "errors"
"html/template"
"net/http"
- "net/mail"
"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"
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/mail"
+ "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/users"
+ "github.com/kirsle/blog/core/internal/render"
+ "github.com/kirsle/blog/core/internal/responses"
+ "github.com/kirsle/blog/core/internal/sessions"
)
-// CommentRoutes attaches the comment routes to the app.
-func (b *Blog) CommentRoutes(r *mux.Router) {
- r.HandleFunc("/comments", b.CommentHandler)
- r.HandleFunc("/comments/subscription", b.SubscriptionHandler)
- r.HandleFunc("/comments/quick-delete", b.QuickDeleteHandler)
+var badRequest func(http.ResponseWriter, *http.Request, string)
+
+// 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.
@@ -34,13 +43,16 @@ type CommentMeta struct {
}
// 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 {
+func RenderComments(r *http.Request, subject string, ids ...string) template.HTML {
id := strings.Join(ids, "-")
+ session := sessions.Get(r)
+ url := r.URL.Path
// 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)
+ csrf, _ := session.Values["csrf"].(string)
// Check if the user is a logged-in admin, to make all comments editable.
var isAdmin bool
@@ -62,10 +74,10 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
// 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.HTML = template.HTML(markdown.RenderMarkdown(c.Body))
c.ThreadID = thread.ID
c.OriginURL = url
- c.CSRF = csrfToken
+ c.CSRF = csrf
// Look up the author username.
if c.UserID > 0 {
@@ -90,14 +102,14 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
}
// Get the template snippet.
- filepath, err := b.ResolvePath("comments/comments.partial")
+ filepath, err := render.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")
+ entryPartial, err := render.ResolvePath("comments/entry.partial")
if err != nil {
log.Error(err.Error())
return template.HTML("[error: missing comments/entry.partial]")
@@ -114,7 +126,7 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
ID: thread.ID,
OriginURL: url,
Subject: subject,
- CSRF: csrfToken,
+ CSRF: csrf,
Thread: &thread,
NewComment: comments.Comment{
Name: name,
@@ -132,22 +144,20 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
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
}
- v := NewVars()
- currentUser, _ := b.CurrentUser(r)
- editToken := b.GetEditToken(w, r)
+ currentUser, _ := auth.CurrentUser(r)
+ editToken := 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.")
+ responses.FlashAndRedirect(w, r, "/", "No thread ID found in the comment form.")
return
}
@@ -168,13 +178,13 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
id := r.FormValue("id")
c, err = t.Find(id)
if err != nil {
- b.FlashAndRedirect(w, r, "/", "That comment was not found.")
+ responses.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.")
+ responses.FlashAndRedirect(w, r, origin, "You don't have permission to edit that comment.")
return
}
@@ -185,16 +195,18 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
// Are we deleting said post?
if submit == "confirm-delete" {
t.Delete(c.ID)
- b.FlashAndRedirect(w, r, origin, "Comment deleted!")
+ responses.FlashAndRedirect(w, r, origin, "Comment deleted!")
return
}
// Cache their name and email in their session.
- session := b.Session(r)
+ session := sessions.Get(r)
session.Values["c.name"] = c.Name
session.Values["c.email"] = c.Email
session.Save(r, w)
+ v := map[string]interface{}{}
+
// Previewing, deleting, or posting?
switch submit {
case "preview", "delete":
@@ -203,10 +215,10 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
c.Email = currentUser.Email
c.LoadAvatar()
}
- c.HTML = template.HTML(b.RenderMarkdown(c.Body))
+ c.HTML = template.HTML(markdown.RenderMarkdown(c.Body))
case "post":
if err := c.Validate(); err != nil {
- v.Error = err
+ 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.
@@ -222,83 +234,48 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
// Append their comment.
err := t.Post(c)
if err != nil {
- b.FlashAndRedirect(w, r, c.OriginURL, "Error posting comment: %s", err)
+ responses.FlashAndRedirect(w, r, c.OriginURL, "Error posting comment: %s", err)
return
}
- b.NotifyComment(c)
+ mail.NotifyComment(c)
// Are they subscribing to future comments?
if c.Subscribe && len(c.Email) > 0 {
if _, err := mail.ParseAddress(c.Email); err == nil {
m := comments.LoadMailingList()
m.Subscribe(t.ID, c.Email)
- b.FlashAndRedirect(w, r, c.OriginURL,
+ responses.FlashAndRedirect(w, r, c.OriginURL,
"Comment posted, and you've been subscribed to "+
"future comments on this page.",
)
return
}
}
- b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!")
+ responses.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!")
log.Info("t: %v", t.Comments)
return
}
}
- v.Data["Thread"] = t
- v.Data["Comment"] = c
- v.Data["Editing"] = c.Editing
- v.Data["Deleting"] = submit == "delete"
+ v["Thread"] = t
+ v["Comment"] = c
+ v["Editing"] = c.Editing
+ v["Deleting"] = submit == "delete"
- b.RenderTemplate(w, r, "comments/index.gohtml", v)
+ render.Template(w, r, "comments/index.gohtml", v)
}
-// SubscriptionHandler to opt out of subscriptions.
-func (b *Blog) SubscriptionHandler(w http.ResponseWriter, r *http.Request) {
- v := NewVars()
-
- // POST to unsubscribe from all threads.
- if r.Method == http.MethodPost {
- email := r.FormValue("email")
- if email == "" {
- v.Error = errors.New("email address is required to unsubscribe from comment threads")
- } else if _, err := mail.ParseAddress(email); err != nil {
- v.Error = errors.New("invalid email address")
- }
-
- m := comments.LoadMailingList()
- m.UnsubscribeAll(email)
- b.FlashAndRedirect(w, r, "/comments/subscription",
- "You have been unsubscribed from all mailing lists.",
- )
- return
- }
-
- // GET to unsubscribe from a single thread.
- thread := r.URL.Query().Get("t")
- email := r.URL.Query().Get("e")
- if thread != "" && email != "" {
- m := comments.LoadMailingList()
- m.Unsubscribe(thread, email)
- b.FlashAndRedirect(w, r, "/comments/subscription", "You have been unsubscribed successfully.")
- return
- }
-
- b.RenderTemplate(w, r, "comments/subscription.gohtml", v)
-}
-
-// QuickDeleteHandler allows the admin to quickly delete spam without logging in.
-func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) {
+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)
+ 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
}
@@ -306,13 +283,13 @@ func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) {
t.Delete(c.ID)
}
- b.FlashAndRedirect(w, r, "/", "Comment deleted!")
+ 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 {
- session := b.Session(r)
+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/contact.go b/core/internal/controllers/contact/contact.go
similarity index 50%
rename from core/contact.go
rename to core/internal/controllers/contact/contact.go
index f8b6f8a..7d3a444 100644
--- a/core/contact.go
+++ b/core/internal/controllers/contact/contact.go
@@ -1,4 +1,4 @@
-package core
+package contact
import (
"fmt"
@@ -9,27 +9,32 @@ import (
"time"
"github.com/gorilla/mux"
- "github.com/kirsle/blog/core/forms"
- "github.com/kirsle/blog/core/models/settings"
+ "github.com/kirsle/blog/core/internal/forms"
+ "github.com/kirsle/blog/core/internal/mail"
+ "github.com/kirsle/blog/core/internal/markdown"
+ "github.com/kirsle/blog/core/internal/models/settings"
+ "github.com/kirsle/blog/core/internal/render"
+ "github.com/kirsle/blog/core/internal/responses"
)
-// ContactRoutes attaches the contact URL to the app.
-func (b *Blog) ContactRoutes(r *mux.Router) {
+// Register attaches the contact URL to the app.
+func Register(r *mux.Router) {
r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) {
- v := NewVars()
- form := forms.Contact{}
- v.Form = &form
+ form := &forms.Contact{}
+ v := map[string]interface{}{
+ "Form": form,
+ }
// If there is no site admin, show an error.
cfg, err := settings.Load()
if err != nil {
- b.Error(w, r, "Error loading site configuration!")
+ responses.Error(w, r, "Error loading site configuration!")
return
} else if cfg.Site.AdminEmail == "" {
- b.Error(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 {
- b.Error(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
}
@@ -37,9 +42,9 @@ func (b *Blog) ContactRoutes(r *mux.Router) {
if r.Method == http.MethodPost {
form.ParseForm(r)
if err = form.Validate(); err != nil {
- b.Flash(w, r, err.Error())
+ responses.Flash(w, r, err.Error())
} else {
- go b.SendEmail(Email{
+ go mail.SendEmail(mail.Email{
To: cfg.Site.AdminEmail,
Admin: true,
ReplyTo: form.Email,
@@ -47,15 +52,15 @@ func (b *Blog) ContactRoutes(r *mux.Router) {
Template: ".email/contact.gohtml",
Data: map[string]interface{}{
"Name": form.Name,
- "Message": template.HTML(b.RenderMarkdown(form.Message)),
+ "Message": template.HTML(markdown.RenderMarkdown(form.Message)),
"Email": form.Email,
},
})
// Log it to disk, too.
- fh, err := os.OpenFile(filepath.Join(b.UserRoot, ".contact.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ fh, err := os.OpenFile(filepath.Join(*render.UserRoot, ".contact.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
- b.Flash(w, r, "Error logging the message to disk: %s", err)
+ responses.Flash(w, r, "Error logging the message to disk: %s", err)
} else {
fh.WriteString(fmt.Sprintf(
"Date: %s\nName: %s\nEmail: %s\nSubject: %s\n\n%s\n\n--------------------\n\n",
@@ -67,10 +72,10 @@ func (b *Blog) ContactRoutes(r *mux.Router) {
))
fh.Close()
}
- b.FlashAndRedirect(w, r, "/contact", "Your message has been sent.")
+ responses.FlashAndRedirect(w, r, "/contact", "Your message has been sent.")
}
}
- b.RenderTemplate(w, r, "contact", v)
+ render.Template(w, r, "contact", v)
})
}
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/controllers/setup/setup.go b/core/internal/controllers/setup/setup.go
new file mode 100644
index 0000000..2e0a093
--- /dev/null
+++ b/core/internal/controllers/setup/setup.go
@@ -0,0 +1,70 @@
+package setup
+
+import (
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/kirsle/blog/core/internal/forms"
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/middleware/auth"
+ "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/kirsle/blog/core/internal/sessions"
+)
+
+// Register the initial setup routes.
+func Register(r *mux.Router) {
+ r.HandleFunc("/initial-setup", handler)
+}
+
+func handler(w http.ResponseWriter, r *http.Request) {
+ form := &forms.Setup{}
+ vars := map[string]interface{}{
+ "Form": form,
+ }
+
+ // Reject if we're already set up.
+ s, _ := settings.Load()
+ if s.Initialized {
+ responses.FlashAndRedirect(w, r, "/", "This website has already been configured.")
+ return
+ }
+
+ if r.Method == http.MethodPost {
+ form.ParseForm(r)
+ err := form.Validate()
+ if err != nil {
+ vars["Error"] = err
+ } else {
+ // Save the site config.
+ log.Info("Creating default website config file")
+ s := settings.Defaults()
+ s.Save()
+
+ // Re-initialize the cookie store with the new secret key.
+ sessions.SetSecretKey([]byte(s.Security.SecretKey))
+
+ log.Info("Creating admin account %s", form.Username)
+ user := &users.User{
+ Username: form.Username,
+ Password: form.Password,
+ Admin: true,
+ Name: "Administrator",
+ }
+ err := users.Create(user)
+ if err != nil {
+ log.Error("Error: %v", err)
+ vars["Error"] = err
+ }
+
+ // All set!
+ auth.Login(w, r, user)
+ responses.FlashAndRedirect(w, r, "/admin", "Admin user created and logged in.")
+ return
+ }
+ }
+
+ render.Template(w, r, "initial-setup", vars)
+}
diff --git a/core/forms/auth.go b/core/internal/forms/auth.go
similarity index 100%
rename from core/forms/auth.go
rename to core/internal/forms/auth.go
diff --git a/core/forms/contact.go b/core/internal/forms/contact.go
similarity index 100%
rename from core/forms/contact.go
rename to core/internal/forms/contact.go
diff --git a/core/forms/forms.go b/core/internal/forms/forms.go
similarity index 60%
rename from core/forms/forms.go
rename to core/internal/forms/forms.go
index a261e60..2e7b9ef 100644
--- a/core/forms/forms.go
+++ b/core/internal/forms/forms.go
@@ -1,6 +1,6 @@
package forms
// Form is an interface for forms that can validate themselves.
-type Form interface {
- Validate() error
-}
+// type Form interface {
+// Validate() error
+// }
diff --git a/core/forms/settings.go b/core/internal/forms/settings.go
similarity index 100%
rename from core/forms/settings.go
rename to core/internal/forms/settings.go
diff --git a/core/forms/setup.go b/core/internal/forms/setup.go
similarity index 71%
rename from core/forms/setup.go
rename to core/internal/forms/setup.go
index dd76a08..18afbd1 100644
--- a/core/forms/setup.go
+++ b/core/internal/forms/setup.go
@@ -2,6 +2,7 @@ package forms
import (
"errors"
+ "net/http"
)
// Setup is for the initial blog setup page at /initial-setup.
@@ -11,6 +12,13 @@ type Setup struct {
Confirm string
}
+// Parse form values.
+func (f *Setup) ParseForm(r *http.Request) {
+ f.Username = r.FormValue("username")
+ f.Password = r.FormValue("password")
+ f.Confirm = r.FormValue("confirm")
+}
+
// Validate the form.
func (f Setup) Validate() error {
if len(f.Username) == 0 {
diff --git a/core/internal/log/log.go b/core/internal/log/log.go
new file mode 100644
index 0000000..c7f4ea7
--- /dev/null
+++ b/core/internal/log/log.go
@@ -0,0 +1,30 @@
+// Package log implements the common logging engine for the blog.
+package log
+
+import "github.com/kirsle/golog"
+
+var log *golog.Logger
+
+func init() {
+ log = golog.GetLogger("blog")
+ log.Configure(&golog.Config{
+ Colors: golog.ExtendedColor,
+ Theme: golog.DarkTheme,
+ })
+}
+
+func Debug(m string, v ...interface{}) {
+ log.Debug(m, v...)
+}
+
+func Info(m string, v ...interface{}) {
+ log.Info(m, v...)
+}
+
+func Warn(m string, v ...interface{}) {
+ log.Warn(m, v...)
+}
+
+func Error(m string, v ...interface{}) {
+ log.Error(m, v...)
+}
diff --git a/core/mail.go b/core/internal/mail/mail.go
similarity index 84%
rename from core/mail.go
rename to core/internal/mail/mail.go
index 7245fac..d5090b9 100644
--- a/core/mail.go
+++ b/core/internal/mail/mail.go
@@ -1,15 +1,18 @@
-package core
+package mail
import (
"bytes"
- "crypto/tls"
"fmt"
"html/template"
+ "net/mail"
"net/url"
"strings"
- "github.com/kirsle/blog/core/models/comments"
- "github.com/kirsle/blog/core/models/settings"
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/markdown"
+ "github.com/kirsle/blog/core/internal/models/comments"
+ "github.com/kirsle/blog/core/internal/models/settings"
+ "github.com/kirsle/blog/core/internal/render"
"github.com/microcosm-cc/bluemonday"
gomail "gopkg.in/gomail.v2"
)
@@ -27,7 +30,7 @@ type Email struct {
}
// SendEmail sends an email.
-func (b *Blog) SendEmail(email Email) {
+func SendEmail(email Email) {
s, _ := settings.Load()
if !s.Mail.Enabled || s.Mail.Host == "" || s.Mail.Port == 0 || s.Mail.Sender == "" {
log.Info("Suppressing email: not completely configured")
@@ -35,7 +38,7 @@ func (b *Blog) SendEmail(email Email) {
}
// Resolve the template.
- tmpl, err := b.ResolvePath(email.Template)
+ tmpl, err := render.ResolvePath(email.Template)
if err != nil {
log.Error("SendEmail: %s", err.Error())
return
@@ -80,11 +83,6 @@ func (b *Blog) SendEmail(email Email) {
m.AddAlternative("text/html", html.String())
d := gomail.NewDialer(s.Mail.Host, s.Mail.Port, s.Mail.Username, s.Mail.Password)
- if b.Debug {
- d.TLSConfig = &tls.Config{
- InsecureSkipVerify: true,
- }
- }
log.Info("SendEmail: %s (%s) to %s", email.Subject, email.Template, email.To)
if err := d.DialAndSend(m); err != nil {
@@ -93,7 +91,7 @@ func (b *Blog) SendEmail(email Email) {
}
// NotifyComment sends notification emails about comments.
-func (b *Blog) NotifyComment(c *comments.Comment) {
+func NotifyComment(c *comments.Comment) {
s, _ := settings.Load()
if s.Site.URL == "" {
log.Error("Can't send comment notification because the site URL is not configured")
@@ -107,7 +105,7 @@ func (b *Blog) NotifyComment(c *comments.Comment) {
Data: map[string]interface{}{
"Name": c.Name,
"Subject": c.Subject,
- "Body": template.HTML(b.RenderMarkdown(c.Body)),
+ "Body": template.HTML(markdown.RenderMarkdown(c.Body)),
"URL": strings.Trim(s.Site.URL, "/") + c.OriginURL,
"QuickDelete": fmt.Sprintf("%s/comments/quick-delete?t=%s&d=%s",
strings.Trim(s.Site.URL, "/"),
@@ -123,7 +121,7 @@ func (b *Blog) NotifyComment(c *comments.Comment) {
email.To = config.Site.AdminEmail
email.Admin = true
log.Info("Mail site admin '%s' about comment notification on '%s'", email.To, c.ThreadID)
- b.SendEmail(email)
+ SendEmail(email)
}
// Email the subscribers.
@@ -140,6 +138,11 @@ func (b *Blog) NotifyComment(c *comments.Comment) {
url.QueryEscape(to),
)
log.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.ThreadID)
- b.SendEmail(email)
+ SendEmail(email)
}
}
+
+// ParseAddress parses an email address.
+func ParseAddress(addr string) (*mail.Address, error) {
+ return mail.ParseAddress(addr)
+}
diff --git a/core/markdown.go b/core/internal/markdown/markdown.go
similarity index 78%
rename from core/markdown.go
rename to core/internal/markdown/markdown.go
index 28953ba..6bb1b47 100644
--- a/core/markdown.go
+++ b/core/internal/markdown/markdown.go
@@ -1,4 +1,5 @@
-package core
+// Package markdown implements a GitHub Flavored Markdown renderer.
+package markdown
import (
"bytes"
@@ -10,17 +11,23 @@ import (
"regexp"
"strings"
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/jsondb/caches"
"github.com/microcosm-cc/bluemonday"
"github.com/shurcooL/github_flavored_markdown"
)
// Regexps for Markdown use cases.
var (
+ // Plug your own Redis cacher in.
+ Cache caches.Cacher
+
// Match title from the first `# h1` heading.
reMarkdownTitle = regexp.MustCompile(`(?m:^#([^#\r\n]+)$)`)
// Match fenced code blocks with languages defined.
- reFencedCode = regexp.MustCompile("```" + `([a-z]*)[\r\n]([\s\S]*?)[\r\n]\s*` + "```")
+ reFencedCode = regexp.MustCompile("```" + `([a-z]*)[\r\n]([\s\S]*?)[\r\n]\s*` + "```")
+ reFencedCodeClass = regexp.MustCompile("^highlight highlight-[a-zA-Z0-9]+$")
// Regexp to match fenced code blocks in rendered Markdown HTML.
// Tweak this if you change Markdown engines later.
@@ -51,8 +58,8 @@ func TitleFromMarkdown(body string) (string, error) {
// RenderMarkdown renders markdown to HTML, safely. It uses blackfriday to
// render Markdown to HTML and then Bluemonday to sanitize the resulting HTML.
-func (b *Blog) RenderMarkdown(input string) string {
- unsafe := []byte(b.RenderTrustedMarkdown(input))
+func RenderMarkdown(input string) string {
+ unsafe := []byte(RenderTrustedMarkdown(input))
// Sanitize HTML, but allow fenced code blocks to not get mangled in user
// submitted comments.
@@ -65,7 +72,7 @@ func (b *Blog) RenderMarkdown(input string) string {
// RenderTrustedMarkdown renders markdown to HTML, but without applying
// bluemonday filtering afterward. This is for blog posts and website
// Markdown pages, not for user-submitted comments or things.
-func (b *Blog) RenderTrustedMarkdown(input string) string {
+func RenderTrustedMarkdown(input string) string {
// Find and hang on to fenced code blocks.
codeBlocks := []codeBlock{}
matches := reFencedCode.FindAllStringSubmatch(input, -1)
@@ -87,7 +94,10 @@ func (b *Blog) RenderTrustedMarkdown(input string) string {
// Substitute fenced codes back in.
for _, block := range codeBlocks {
- highlighted, _ := b.Pygmentize(block.language, block.source)
+ highlighted, err := Pygmentize(block.language, block.source)
+ if err != nil {
+ log.Error("Pygmentize error: %s", err)
+ }
html = strings.Replace(html,
fmt.Sprintf("[?FENCED_CODE_%d_BLOCK?]", block.placeholder),
highlighted,
@@ -105,7 +115,7 @@ func (b *Blog) RenderTrustedMarkdown(input string) string {
//
// The rendered result is cached in Redis if available, because the CLI
// call takes ~0.6s which is slow if you're rendering a lot of code blocks.
-func (b *Blog) Pygmentize(language, source string) (string, error) {
+func Pygmentize(language, source string) (string, error) {
var result string
// Hash the source for the cache key.
@@ -115,8 +125,10 @@ func (b *Blog) Pygmentize(language, source string) (string, error) {
cacheKey := "pygmentize:" + hash
// Do we have it cached?
- if cached, err := b.Cache.Get(cacheKey); err == nil && len(cached) > 0 {
- return string(cached), nil
+ if Cache != nil {
+ if cached, err := Cache.Get(cacheKey); err == nil && len(cached) > 0 {
+ return string(cached), nil
+ }
}
// Defer to the `pygmentize` command
@@ -140,9 +152,11 @@ func (b *Blog) Pygmentize(language, source string) (string, error) {
}
result = out.String()
- err := b.Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change
- if err != nil {
- log.Error("Couldn't cache Pygmentize output: %s", err)
+ if Cache != nil {
+ err := Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change
+ if err != nil {
+ log.Error("Couldn't cache Pygmentize output: %s", err)
+ }
}
return result, nil
diff --git a/core/internal/middleware/auth/auth.go b/core/internal/middleware/auth/auth.go
new file mode 100644
index 0000000..74b410d
--- /dev/null
+++ b/core/internal/middleware/auth/auth.go
@@ -0,0 +1,76 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "github.com/kirsle/blog/core/internal/models/users"
+ "github.com/kirsle/blog/core/internal/sessions"
+ "github.com/kirsle/blog/core/internal/types"
+ "github.com/urfave/negroni"
+)
+
+// CurrentUser returns the current user's object.
+func CurrentUser(r *http.Request) (*users.User, error) {
+ session := sessions.Get(r)
+ if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
+ id := session.Values["user-id"].(int)
+ u, err := users.LoadReadonly(id)
+ u.IsAuthenticated = true
+ return u, err
+ }
+
+ return &users.User{
+ Admin: false,
+ }, errors.New("not authenticated")
+}
+
+// Login logs the browser in as the given user.
+func Login(w http.ResponseWriter, r *http.Request, u *users.User) error {
+ session, err := sessions.Store.Get(r, "session") // TODO session name
+ if err != nil {
+ return err
+ }
+ session.Values["logged-in"] = true
+ session.Values["user-id"] = u.ID
+ session.Save(r, w)
+ return nil
+}
+
+// LoggedIn returns whether the current user is logged in to an account.
+func LoggedIn(r *http.Request) bool {
+ session := sessions.Get(r)
+ if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
+ return true
+ }
+ return false
+}
+
+// LoginRequired is a middleware that requires a logged-in user.
+func LoginRequired(onError http.HandlerFunc) negroni.HandlerFunc {
+ middleware := func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
+ ctx := r.Context()
+ if user, ok := ctx.Value(types.UserKey).(*users.User); ok {
+ if user.ID > 0 {
+ next(w, r)
+ return
+ }
+ }
+ onError(w, r)
+ }
+
+ return middleware
+}
+
+// Middleware loads the user's authentication state from their session cookie.
+func Middleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
+ u, err := CurrentUser(r)
+ if err != nil {
+ next(w, r)
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), types.UserKey, u)
+ next(w, r.WithContext(ctx))
+}
diff --git a/core/internal/middleware/csrf.go b/core/internal/middleware/csrf.go
new file mode 100644
index 0000000..37d68b3
--- /dev/null
+++ b/core/internal/middleware/csrf.go
@@ -0,0 +1,55 @@
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/google/uuid"
+ gorilla "github.com/gorilla/sessions"
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/sessions"
+ "github.com/urfave/negroni"
+)
+
+// CSRF is a middleware generator that enforces CSRF tokens on all POST requests.
+func CSRF(onError func(http.ResponseWriter, *http.Request, string)) negroni.HandlerFunc {
+ middleware := func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
+ if r.Method == "POST" {
+ session := sessions.Get(r)
+ token := GenerateCSRFToken(w, r, session)
+ if token != r.FormValue("_csrf") {
+ log.Error("CSRF Mismatch: expected %s, got %s", r.FormValue("_csrf"), token)
+ onError(w, r, "Failed to validate CSRF token. Please try your request again.")
+ return
+ }
+ }
+ next(w, r)
+ }
+
+ return middleware
+}
+
+// ExampleCSRF shows how to use the CSRF handler.
+func ExampleCSRF() {
+ // Your error handling for CSRF failures.
+ onError := func(w http.ResponseWriter, r *http.Request, message string) {
+ w.Write([]byte("CSRF Error: " + message))
+ }
+
+ // Attach the middleware.
+ _ = negroni.New(
+ negroni.NewRecovery(),
+ negroni.NewLogger(),
+ negroni.HandlerFunc(CSRF(onError)),
+ )
+}
+
+// GenerateCSRFToken generates a CSRF token for the user and puts it in their session.
+func GenerateCSRFToken(w http.ResponseWriter, r *http.Request, session *gorilla.Session) string {
+ token, ok := session.Values["csrf"].(string)
+ if !ok {
+ token := uuid.New()
+ session.Values["csrf"] = token.String()
+ session.Save(r, w)
+ }
+ return token
+}
diff --git a/core/models/comments/comments.go b/core/internal/models/comments/comments.go
similarity index 99%
rename from core/models/comments/comments.go
rename to core/internal/models/comments/comments.go
index 816b5ef..7e3345b 100644
--- a/core/models/comments/comments.go
+++ b/core/internal/models/comments/comments.go
@@ -11,7 +11,7 @@ import (
"time"
"github.com/google/uuid"
- "github.com/kirsle/blog/core/jsondb"
+ "github.com/kirsle/blog/jsondb"
"github.com/kirsle/golog"
)
diff --git a/core/models/comments/subscribers.go b/core/internal/models/comments/subscribers.go
similarity index 100%
rename from core/models/comments/subscribers.go
rename to core/internal/models/comments/subscribers.go
diff --git a/core/models/posts/index.go b/core/internal/models/posts/index.go
similarity index 100%
rename from core/models/posts/index.go
rename to core/internal/models/posts/index.go
diff --git a/core/models/posts/posts.go b/core/internal/models/posts/posts.go
similarity index 99%
rename from core/models/posts/posts.go
rename to core/internal/models/posts/posts.go
index f25d80f..2ce884e 100644
--- a/core/models/posts/posts.go
+++ b/core/internal/models/posts/posts.go
@@ -9,7 +9,7 @@ import (
"strings"
"time"
- "github.com/kirsle/blog/core/jsondb"
+ "github.com/kirsle/blog/jsondb"
"github.com/kirsle/golog"
)
diff --git a/core/models/posts/sorting.go b/core/internal/models/posts/sorting.go
similarity index 100%
rename from core/models/posts/sorting.go
rename to core/internal/models/posts/sorting.go
diff --git a/core/models/settings/settings.go b/core/internal/models/settings/settings.go
similarity index 98%
rename from core/models/settings/settings.go
rename to core/internal/models/settings/settings.go
index d933ffd..9407c3f 100644
--- a/core/models/settings/settings.go
+++ b/core/internal/models/settings/settings.go
@@ -4,7 +4,7 @@ import (
"crypto/rand"
"encoding/base64"
- "github.com/kirsle/blog/core/jsondb"
+ "github.com/kirsle/blog/jsondb"
)
// DB is a reference to the parent app's JsonDB object.
diff --git a/core/models/users/users.go b/core/internal/models/users/users.go
similarity index 99%
rename from core/models/users/users.go
rename to core/internal/models/users/users.go
index ab74307..e125704 100644
--- a/core/models/users/users.go
+++ b/core/internal/models/users/users.go
@@ -6,7 +6,7 @@ import (
"strconv"
"strings"
- "github.com/kirsle/blog/core/jsondb"
+ "github.com/kirsle/blog/jsondb"
"golang.org/x/crypto/bcrypt"
)
diff --git a/core/models/users/utils.go b/core/internal/models/users/utils.go
similarity index 100%
rename from core/models/users/utils.go
rename to core/internal/models/users/utils.go
diff --git a/core/internal/render/functions.go b/core/internal/render/functions.go
new file mode 100644
index 0000000..9e18b3c
--- /dev/null
+++ b/core/internal/render/functions.go
@@ -0,0 +1,14 @@
+package render
+
+import (
+ "html/template"
+ "strings"
+ "time"
+)
+
+// Funcs is a global funcmap that the blog can hook its internal
+// methods onto.
+var Funcs = template.FuncMap{
+ "StringsJoin": strings.Join,
+ "Now": time.Now,
+}
diff --git a/core/internal/render/resolve_paths.go b/core/internal/render/resolve_paths.go
new file mode 100644
index 0000000..6f3b280
--- /dev/null
+++ b/core/internal/render/resolve_paths.go
@@ -0,0 +1,92 @@
+package render
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/kirsle/blog/core/internal/log"
+)
+
+// Blog configuration bindings.
+var (
+ UserRoot *string
+ DocumentRoot *string
+)
+
+// Filepath represents a file discovered in the document roots, and maintains
+// both its relative and absolute components.
+type Filepath struct {
+ // Canonicalized URI version of the file resolved on disk,
+ // possible with a file extension injected.
+ // (i.e. "/about" -> "about.html")
+ URI string
+ Basename string
+ Relative string // Relative path including document root (i.e. "root/about.html")
+ Absolute string // Absolute path on disk (i.e. "/opt/blog/root/about.html")
+}
+
+func (f Filepath) String() string {
+ return f.Relative
+}
+
+// ResolvePath matches a filesystem path to a relative request URI.
+//
+// This checks the UserRoot first and then the DocumentRoot. This way the user
+// may override templates from the core app's document root.
+func ResolvePath(path string) (Filepath, error) {
+ // Strip leading slashes.
+ if path[0] == '/' {
+ path = strings.TrimPrefix(path, "/")
+ }
+
+ // If you need to debug this function, edit this block.
+ debug := func(tmpl string, args ...interface{}) {
+ if false {
+ log.Debug(tmpl, args...)
+ }
+ }
+
+ debug("Resolving filepath for URI: %s", path)
+ for _, root := range []string{*UserRoot, *DocumentRoot} {
+ if len(root) == 0 {
+ continue
+ }
+
+ // Resolve the file path.
+ relPath := filepath.Join(root, path)
+ absPath, err := filepath.Abs(relPath)
+ basename := filepath.Base(relPath)
+ if err != nil {
+ log.Error("%v", err)
+ }
+
+ debug("Expected filepath: %s", absPath)
+
+ // Found an exact hit?
+ if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() {
+ debug("Exact filepath found: %s", absPath)
+ return Filepath{path, basename, relPath, absPath}, nil
+ }
+
+ // Try some supported suffixes.
+ suffixes := []string{
+ ".gohtml",
+ ".html",
+ "/index.gohtml",
+ "/index.html",
+ ".md",
+ "/index.md",
+ }
+ for _, suffix := range suffixes {
+ test := absPath + suffix
+ if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() {
+ debug("Filepath found via suffix %s: %s", suffix, test)
+ return Filepath{path + suffix, basename + suffix, relPath + suffix, test}, nil
+ }
+ }
+ }
+
+ return Filepath{}, errors.New("not found")
+}
diff --git a/core/internal/render/templates.go b/core/internal/render/templates.go
new file mode 100644
index 0000000..0b7affe
--- /dev/null
+++ b/core/internal/render/templates.go
@@ -0,0 +1,148 @@
+package render
+
+import (
+ "html/template"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/middleware"
+ "github.com/kirsle/blog/core/internal/middleware/auth"
+ "github.com/kirsle/blog/core/internal/models/settings"
+ "github.com/kirsle/blog/core/internal/models/users"
+ "github.com/kirsle/blog/core/internal/sessions"
+ "github.com/kirsle/blog/core/internal/types"
+)
+
+// Vars is an interface to implement by the templates to pass their own custom
+// variables in. It auto-loads global template variables (site name, etc.)
+// when the template is rendered.
+type vars struct {
+ // Global, "constant" template variables.
+ SetupNeeded bool
+ Title string
+ Path string
+ TemplatePath string // actual template file on disk
+ LoggedIn bool
+ CurrentUser *users.User
+ CSRF string
+ Editable bool // page is editable
+ Request *http.Request
+ RequestTime time.Time
+ RequestDuration time.Duration
+
+ // Common template variables.
+ Message string
+ Flashes []string
+ Error error
+ Data interface{}
+}
+
+// Template responds with an HTML template.
+//
+// The vars will be massaged a bit to load the global defaults (such as the
+// website title and user login status), the user's session may be updated with
+// new CSRF token, and other such things. If you just want to render a template
+// without all that nonsense, use RenderPartialTemplate.
+func Template(w io.Writer, r *http.Request, path string, data interface{}) error {
+ isPartial := strings.Contains(path, ".partial")
+
+ // Get the site settings.
+ s, err := settings.Load()
+ if err != nil {
+ s = settings.Defaults()
+ }
+
+ // Inject globally available variables.
+ v := vars{
+ SetupNeeded: s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup"),
+
+ Request: r,
+ RequestTime: r.Context().Value(types.StartTimeKey).(time.Time),
+ Title: s.Site.Title,
+ Path: r.URL.Path,
+
+ Data: data,
+ }
+
+ user, err := auth.CurrentUser(r)
+ v.CurrentUser = user
+ v.LoggedIn = err == nil
+
+ // If this is the HTTP response, handle session-related things.
+ if rw, ok := w.(http.ResponseWriter); ok {
+ rw.Header().Set("Content-Type", "text/html; encoding=UTF-8")
+ session := sessions.Get(r)
+
+ // Flashed messages.
+ if flashes := session.Flashes(); len(flashes) > 0 {
+ for _, flash := range flashes {
+ _ = flash
+ v.Flashes = append(v.Flashes, flash.(string))
+ }
+ session.Save(r, rw)
+ }
+
+ // CSRF token for forms.
+ v.CSRF = middleware.GenerateCSRFToken(rw, r, session)
+ }
+
+ v.RequestDuration = time.Now().Sub(v.RequestTime)
+ v.Editable = !strings.HasPrefix(path, "admin/")
+
+ var (
+ layout Filepath
+ templateName string
+ )
+
+ // Find the file path to the template.
+ filepath, err := ResolvePath(path)
+ if err != nil {
+ log.Error("RenderTemplate(%s): file not found", path)
+ return err
+ }
+ v.TemplatePath = filepath.URI
+
+ // Get the layout template.
+ if !isPartial {
+ templateName = "layout"
+ layout, err = ResolvePath(".layout")
+ if err != nil {
+ log.Error("RenderTemplate(%s): layout template not found", path)
+ return err
+ }
+ } else {
+ templateName = filepath.Basename
+ }
+
+ // The comment entry partial.
+ commentEntry, err := ResolvePath("comments/entry.partial")
+ if err != nil {
+ log.Error("RenderTemplate(%s): comments/entry.partial not found")
+ return err
+ }
+
+ t := template.New(filepath.Absolute).Funcs(Funcs)
+
+ // Parse the template files. The layout comes first because it's the wrapper
+ // and allows the filepath template to set the page title.
+ var templates []string
+ if !isPartial {
+ templates = append(templates, layout.Absolute)
+ }
+ t, err = t.ParseFiles(append(templates, commentEntry.Absolute, filepath.Absolute)...)
+ if err != nil {
+ log.Error(err.Error())
+ return err
+ }
+
+ err = t.ExecuteTemplate(w, templateName, v)
+ if err != nil {
+ log.Error("Template parsing error: %s", err)
+ return err
+ }
+
+ return nil
+}
diff --git a/core/internal/responses/responses.go b/core/internal/responses/responses.go
new file mode 100644
index 0000000..cef2963
--- /dev/null
+++ b/core/internal/responses/responses.go
@@ -0,0 +1,41 @@
+package responses
+
+import (
+ "fmt"
+ "net/http"
+
+ "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)
+ session.AddFlash(fmt.Sprintf(message, args...))
+ session.Save(r, w)
+}
+
+// FlashAndRedirect flashes and redirects in one go.
+func FlashAndRedirect(w http.ResponseWriter, r *http.Request, location, message string, args ...interface{}) {
+ Flash(w, r, message, args...)
+ Redirect(w, location)
+}
+
+// FlashAndReload flashes and sends a redirect to the same path.
+func FlashAndReload(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) {
+ Flash(w, r, message, args...)
+ Redirect(w, r.URL.Path)
+}
+
+// Redirect sends an HTTP redirect response.
+func Redirect(w http.ResponseWriter, location string) {
+ w.Header().Set("Location", location)
+ w.WriteHeader(http.StatusFound)
+}
diff --git a/core/internal/sessions/sessions.go b/core/internal/sessions/sessions.go
new file mode 100644
index 0000000..3c9ec3e
--- /dev/null
+++ b/core/internal/sessions/sessions.go
@@ -0,0 +1,56 @@
+package sessions
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/sessions"
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/types"
+)
+
+// Store holds your cookie store information.
+var Store sessions.Store
+
+// SetSecretKey initializes a session cookie store with the secret key.
+func SetSecretKey(keyPairs ...[]byte) {
+ Store = sessions.NewCookieStore(keyPairs...)
+}
+
+// Middleware gets the Gorilla session store and makes it available on the
+// Request context.
+//
+// Middleware is the first custom middleware applied, so it takes the current
+// datetime to make available later in the request and stores it on the request
+// context.
+func Middleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
+ // Store the current datetime on the request context.
+ ctx := context.WithValue(r.Context(), types.StartTimeKey, time.Now())
+
+ // Get the Gorilla session and make it available in the request context.
+ session, _ := Store.Get(r, "session")
+ ctx = context.WithValue(ctx, types.SessionKey, session)
+
+ next(w, r.WithContext(ctx))
+}
+
+// Get returns the current request's session.
+func Get(r *http.Request) *sessions.Session {
+ if r == nil {
+ panic("Session(*http.Request) with a nil argument!?")
+ }
+
+ ctx := r.Context()
+ if session, ok := ctx.Value(types.SessionKey).(*sessions.Session); ok {
+ return session
+ }
+
+ // If the session wasn't on the request, it means I broke something.
+ log.Error(
+ "Session(): didn't find session in request context! Getting it " +
+ "from the session store instead.",
+ )
+ session, _ := Store.Get(r, "session")
+ return session
+}
diff --git a/core/constants.go b/core/internal/types/constants.go
similarity index 71%
rename from core/constants.go
rename to core/internal/types/constants.go
index ba1bcb0..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
@@ -19,3 +19,11 @@ const (
MARKDOWN ContentType = "markdown"
HTML ContentType = "html"
)
+
+// Common form actions.
+const (
+ ActionSave = "save"
+ ActionDelete = "delete"
+ ActionPreview = "preview"
+ ActionPost = "post"
+)
diff --git a/core/internal/types/context.go b/core/internal/types/context.go
new file mode 100644
index 0000000..67bd6a7
--- /dev/null
+++ b/core/internal/types/context.go
@@ -0,0 +1,11 @@
+package types
+
+// Key is an integer enum for context.Context keys.
+type Key int
+
+// Key definitions.
+const (
+ SessionKey Key = iota // The request's cookie session object.
+ UserKey // The request's user data for logged-in users.
+ StartTimeKey // HTTP request start time.
+)
diff --git a/core/log.go b/core/log.go
deleted file mode 100644
index 8189ada..0000000
--- a/core/log.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package core
-
-import "github.com/kirsle/golog"
-
-var log *golog.Logger
-
-func init() {
- log = golog.GetLogger("blog")
- log.Configure(&golog.Config{
- Colors: golog.ExtendedColor,
- Theme: golog.DarkTheme,
- })
-}
diff --git a/core/middleware.go b/core/middleware.go
deleted file mode 100644
index 0fc8d8f..0000000
--- a/core/middleware.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package core
-
-import (
- "context"
- "errors"
- "net/http"
- "time"
-
- "github.com/google/uuid"
- "github.com/gorilla/sessions"
- "github.com/kirsle/blog/core/models/users"
-)
-
-type key int
-
-const (
- sessionKey key = iota
- userKey
- requestTimeKey
-)
-
-// SessionLoader gets the Gorilla session store and makes it available on the
-// Request context.
-//
-// SessionLoader is the first custom middleware applied, so it takes the current
-// datetime to make available later in the request and stores it on the request
-// context.
-func (b *Blog) SessionLoader(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
- // Store the current datetime on the request context.
- ctx := context.WithValue(r.Context(), requestTimeKey, time.Now())
-
- // Get the Gorilla session and make it available in the request context.
- session, _ := b.store.Get(r, "session")
- ctx = context.WithValue(ctx, sessionKey, session)
-
- next(w, r.WithContext(ctx))
-}
-
-// Session returns the current request's session.
-func (b *Blog) Session(r *http.Request) *sessions.Session {
- ctx := r.Context()
- if session, ok := ctx.Value(sessionKey).(*sessions.Session); ok {
- return session
- }
-
- log.Error(
- "Session(): didn't find session in request context! Getting it " +
- "from the session store instead.",
- )
- session, _ := b.store.Get(r, "session")
- return session
-}
-
-// CSRFMiddleware enforces CSRF tokens on all POST requests.
-func (b *Blog) CSRFMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
- if r.Method == "POST" {
- session := b.Session(r)
- token := b.GenerateCSRFToken(w, r, session)
- if token != r.FormValue("_csrf") {
- log.Error("CSRF Mismatch: expected %s, got %s", r.FormValue("_csrf"), token)
- b.Forbidden(w, r, "Failed to validate CSRF token. Please try your request again.")
- return
- }
- }
-
- next(w, r)
-}
-
-// GenerateCSRFToken generates a CSRF token for the user and puts it in their session.
-func (b *Blog) GenerateCSRFToken(w http.ResponseWriter, r *http.Request, session *sessions.Session) string {
- token, ok := session.Values["csrf"].(string)
- if !ok {
- token := uuid.New()
- session.Values["csrf"] = token.String()
- session.Save(r, w)
- }
- return token
-}
-
-// CurrentUser returns the current user's object.
-func (b *Blog) CurrentUser(r *http.Request) (*users.User, error) {
- session := b.Session(r)
- if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
- id := session.Values["user-id"].(int)
- u, err := users.LoadReadonly(id)
- u.IsAuthenticated = true
- return u, err
- }
-
- return &users.User{
- Admin: false,
- }, errors.New("not authenticated")
-}
-
-// LoggedIn returns whether the current user is logged in to an account.
-func (b *Blog) LoggedIn(r *http.Request) bool {
- session := b.Session(r)
- if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
- return true
- }
- return false
-}
-
-// AuthMiddleware loads the user's authentication state.
-func (b *Blog) AuthMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
- u, err := b.CurrentUser(r)
- if err != nil {
- next(w, r)
- return
- }
-
- ctx := context.WithValue(r.Context(), userKey, u)
- next(w, r.WithContext(ctx))
-}
-
-// LoginRequired is a middleware that requires a logged-in user.
-func (b *Blog) LoginRequired(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
- ctx := r.Context()
- if user, ok := ctx.Value(userKey).(*users.User); ok {
- if user.ID > 0 {
- next(w, r)
- return
- }
- }
-
- log.Info("Redirect away!")
- b.Redirect(w, "/login?next="+r.URL.Path)
-}
diff --git a/core/pages.go b/core/pages.go
index 6712a57..6b6a068 100644
--- a/core/pages.go
+++ b/core/pages.go
@@ -1,13 +1,16 @@
package core
import (
- "errors"
"html/template"
"io/ioutil"
"net/http"
- "os"
- "path/filepath"
"strings"
+
+ "github.com/kirsle/blog/core/internal/controllers/posts"
+ "github.com/kirsle/blog/core/internal/log"
+ "github.com/kirsle/blog/core/internal/markdown"
+ "github.com/kirsle/blog/core/internal/render"
+ "github.com/kirsle/blog/core/internal/responses"
)
// PageHandler is the catch-all route handler, for serving static web pages.
@@ -17,30 +20,31 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
// Remove trailing slashes by redirecting them away.
if len(path) > 1 && path[len(path)-1] == '/' {
- b.Redirect(w, strings.TrimRight(path, "/"))
+ responses.Redirect(w, strings.TrimRight(path, "/"))
return
}
// Restrict special paths.
if strings.HasPrefix(strings.ToLower(path), "/.") {
- b.Forbidden(w, r)
+ responses.Forbidden(w, r, "Forbidden")
return
}
// Search for a file that matches their URL.
- filepath, err := b.ResolvePath(path)
+ 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.")
+ log.Error("Post by fragment %s not found: %s", path, err)
+ responses.NotFound(w, r, "The page you were looking for was not found.")
}
return
}
// Is it a template file?
if strings.HasSuffix(filepath.URI, ".gohtml") {
- b.RenderTemplate(w, r, filepath.URI, nil)
+ render.Template(w, r, filepath.URI, nil)
return
}
@@ -48,98 +52,22 @@ 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
}
// Render it to HTML and find out its title.
body := string(source)
- html := b.RenderTrustedMarkdown(body)
- title, _ := TitleFromMarkdown(body)
+ html := markdown.RenderTrustedMarkdown(body)
+ title, _ := markdown.TitleFromMarkdown(body)
- b.RenderTemplate(w, r, ".markdown", NewVars(map[interface{}]interface{}{
+ render.Template(w, r, ".markdown", map[string]interface{}{
"Title": title,
"HTML": template.HTML(html),
- "MarkdownFile": filepath.URI,
- }))
+ "MarkdownPath": filepath.URI,
+ })
return
}
http.ServeFile(w, r, filepath.Absolute)
}
-
-// Filepath represents a file discovered in the document roots, and maintains
-// both its relative and absolute components.
-type Filepath struct {
- // Canonicalized URI version of the file resolved on disk,
- // possible with a file extension injected.
- // (i.e. "/about" -> "about.html")
- URI string
- Basename string
- Relative string // Relative path including document root (i.e. "root/about.html")
- Absolute string // Absolute path on disk (i.e. "/opt/blog/root/about.html")
-}
-
-func (f Filepath) String() string {
- return f.Relative
-}
-
-// ResolvePath matches a filesystem path to a relative request URI.
-//
-// This checks the UserRoot first and then the DocumentRoot. This way the user
-// may override templates from the core app's document root.
-func (b *Blog) ResolvePath(path string) (Filepath, error) {
- // Strip leading slashes.
- if path[0] == '/' {
- path = strings.TrimPrefix(path, "/")
- }
-
- // If you need to debug this function, edit this block.
- debug := func(tmpl string, args ...interface{}) {
- if false {
- log.Debug(tmpl, args...)
- }
- }
-
- debug("Resolving filepath for URI: %s", path)
- for _, root := range []string{b.UserRoot, b.DocumentRoot} {
- if len(root) == 0 {
- continue
- }
-
- // Resolve the file path.
- relPath := filepath.Join(root, path)
- absPath, err := filepath.Abs(relPath)
- basename := filepath.Base(relPath)
- if err != nil {
- log.Error("%v", err)
- }
-
- debug("Expected filepath: %s", absPath)
-
- // Found an exact hit?
- if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() {
- debug("Exact filepath found: %s", absPath)
- return Filepath{path, basename, relPath, absPath}, nil
- }
-
- // Try some supported suffixes.
- suffixes := []string{
- ".gohtml",
- ".html",
- "/index.gohtml",
- "/index.html",
- ".md",
- "/index.md",
- }
- for _, suffix := range suffixes {
- test := absPath + suffix
- if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() {
- debug("Filepath found via suffix %s: %s", suffix, test)
- return Filepath{path + suffix, basename + suffix, relPath + suffix, test}, nil
- }
- }
- }
-
- return Filepath{}, errors.New("not found")
-}
diff --git a/core/regexp.go b/core/regexp.go
deleted file mode 100644
index 3a3ffbe..0000000
--- a/core/regexp.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package core
-
-import "regexp"
-
-var (
- // CSS classes for Markdown fenced code blocks
- reFencedCodeClass = regexp.MustCompile("^highlight highlight-[a-zA-Z0-9]+$")
-)
diff --git a/core/responses.go b/core/responses.go
deleted file mode 100644
index 92e0ebb..0000000
--- a/core/responses.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package core
-
-import (
- "fmt"
- "net/http"
-)
-
-// Flash adds a flash message to the user's session.
-func (b *Blog) Flash(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) {
- session := b.Session(r)
- session.AddFlash(fmt.Sprintf(message, args...))
- session.Save(r, w)
-}
-
-// FlashAndRedirect flashes and redirects in one go.
-func (b *Blog) FlashAndRedirect(w http.ResponseWriter, r *http.Request, location, message string, args ...interface{}) {
- b.Flash(w, r, message, args...)
- b.Redirect(w, location)
-}
-
-// FlashAndReload flashes and sends a redirect to the same path.
-func (b *Blog) FlashAndReload(w http.ResponseWriter, r *http.Request, message string, args ...interface{}) {
- b.Flash(w, r, message, args...)
- b.Redirect(w, r.URL.Path)
-}
-
-// Redirect sends an HTTP redirect response.
-func (b *Blog) Redirect(w http.ResponseWriter, location string) {
- w.Header().Set("Location", location)
- w.WriteHeader(http.StatusFound)
-}
-
-// NotFound sends a 404 response.
-func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...string) {
- if len(message) == 0 {
- message = []string{"The page you were looking for was not found."}
- }
-
- w.WriteHeader(http.StatusNotFound)
- err := b.RenderTemplate(w, r, ".errors/404", &Vars{
- Message: message[0],
- })
- if err != nil {
- log.Error(err.Error())
- w.Write([]byte("Unrecoverable template error for NotFound()"))
- }
-}
-
-// Forbidden sends an HTTP 403 Forbidden response.
-func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) {
- w.WriteHeader(http.StatusForbidden)
- err := b.RenderTemplate(w, r, ".errors/403", &Vars{
- Message: message[0],
- })
- if err != nil {
- log.Error(err.Error())
- w.Write([]byte("Unrecoverable template error for Forbidden()"))
- }
-}
-
-// 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 := b.RenderTemplate(w, r, ".errors/500", &Vars{
- Message: message[0],
- })
- if err != nil {
- log.Error(err.Error())
- w.Write([]byte("Unrecoverable template error for Error()"))
- }
-}
-
-// BadRequest sends an HTTP 400 Bad Request.
-func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message ...string) {
- w.WriteHeader(http.StatusBadRequest)
- err := b.RenderTemplate(w, r, ".errors/400", &Vars{
- Message: message[0],
- })
- if err != nil {
- log.Error(err.Error())
- w.Write([]byte("Unrecoverable template error for BadRequest()"))
- }
-}
diff --git a/core/templates.go b/core/templates.go
deleted file mode 100644
index ff19298..0000000
--- a/core/templates.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package core
-
-import (
- "html/template"
- "io"
- "net/http"
- "strings"
- "time"
-
- "github.com/kirsle/blog/core/forms"
- "github.com/kirsle/blog/core/models/settings"
- "github.com/kirsle/blog/core/models/users"
-)
-
-// Vars is an interface to implement by the templates to pass their own custom
-// variables in. It auto-loads global template variables (site name, etc.)
-// when the template is rendered.
-type Vars struct {
- // Global, "constant" template variables.
- SetupNeeded bool
- Title string
- Path string
- LoggedIn bool
- CurrentUser *users.User
- CSRF string
- Editable bool // page is editable
- Request *http.Request
- RequestTime time.Time
- RequestDuration time.Duration
-
- // Configuration variables
- NoLayout bool // don't wrap in .layout.html, just render the template
-
- // Common template variables.
- Message string
- Flashes []string
- Error error
- Data map[interface{}]interface{}
- Form forms.Form
-}
-
-// NewVars initializes a Vars struct with the custom Data map initialized.
-// You may pass in an initial value for this map if you want.
-func NewVars(data ...map[interface{}]interface{}) *Vars {
- var value map[interface{}]interface{}
- if len(data) > 0 {
- value = data[0]
- } else {
- value = make(map[interface{}]interface{})
- }
- return &Vars{
- Data: value,
- }
-}
-
-// LoadDefaults combines template variables with default, globally available vars.
-func (v *Vars) LoadDefaults(b *Blog, r *http.Request) {
- // Get the site settings.
- s, err := settings.Load()
- if err != nil {
- s = settings.Defaults()
- }
-
- if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup") {
- v.SetupNeeded = true
- }
- v.Request = r
- v.RequestTime = r.Context().Value(requestTimeKey).(time.Time)
- v.Title = s.Site.Title
- v.Path = r.URL.Path
-
- user, err := b.CurrentUser(r)
- v.CurrentUser = user
- v.LoggedIn = err == nil
-}
-
-// // TemplateVars is an interface that describes the template variable struct.
-// type TemplateVars interface {
-// LoadDefaults(*Blog, *http.Request)
-// }
-
-// RenderPartialTemplate handles rendering a Go template to a writer, without
-// doing anything extra to the vars or dealing with net/http. This is ideal for
-// rendering partials, such as comment partials.
-//
-// This will wrap the template in `.layout.gohtml` by default. To render just
-// a bare template on its own, i.e. for partial templates, create a Vars struct
-// with `Vars{NoIndex: true}`
-func (b *Blog) RenderPartialTemplate(w io.Writer, path string, v interface{}, withLayout bool, functions map[string]interface{}) error {
- var (
- layout Filepath
- templateName string
- err error
- )
-
- // Find the file path to the template.
- filepath, err := b.ResolvePath(path)
- if err != nil {
- log.Error("RenderTemplate(%s): file not found", path)
- return err
- }
-
- // Get the layout template.
- if withLayout {
- templateName = "layout"
- layout, err = b.ResolvePath(".layout")
- if err != nil {
- log.Error("RenderTemplate(%s): layout template not found", path)
- return err
- }
- } else {
- templateName = filepath.Basename
- }
-
- // 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
- }
-
- // Template functions.
- funcmap := template.FuncMap{
- "StringsJoin": strings.Join,
- "Now": time.Now,
- "RenderIndex": b.RenderIndex,
- "RenderPost": b.RenderPost,
- "RenderTags": b.RenderTags,
- "TemplateName": func() string {
- return filepath.URI
- },
- }
- if functions != nil {
- for name, fn := range functions {
- funcmap[name] = fn
- }
- }
-
- // Useful template functions.
- t := template.New(filepath.Absolute).Funcs(funcmap)
-
- // Parse the template files. The layout comes first because it's the wrapper
- // and allows the filepath template to set the page title.
- var templates []string
- if withLayout {
- templates = append(templates, layout.Absolute)
- }
- t, err = t.ParseFiles(append(templates, commentEntry.Absolute, filepath.Absolute)...)
- if err != nil {
- log.Error(err.Error())
- return err
- }
-
- err = t.ExecuteTemplate(w, templateName, v)
- if err != nil {
- log.Error("Template parsing error: %s", err)
- return err
- }
-
- return nil
-}
-
-// RenderTemplate responds with an HTML template.
-//
-// The vars will be massaged a bit to load the global defaults (such as the
-// website title and user login status), the user's session may be updated with
-// new CSRF token, and other such things. If you just want to render a template
-// without all that nonsense, use RenderPartialTemplate.
-func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path string, vars *Vars) error {
- // Inject globally available variables.
- if vars == nil {
- vars = &Vars{}
- }
- vars.LoadDefaults(b, r)
-
- // Add any flashed messages from the endpoint controllers.
- session := b.Session(r)
- if flashes := session.Flashes(); len(flashes) > 0 {
- for _, flash := range flashes {
- _ = flash
- vars.Flashes = append(vars.Flashes, flash.(string))
- }
- session.Save(r, w)
- }
-
- vars.RequestDuration = time.Now().Sub(vars.RequestTime)
- vars.CSRF = b.GenerateCSRFToken(w, r, session)
- vars.Editable = !strings.HasPrefix(path, "admin/")
-
- w.Header().Set("Content-Type", "text/html; encoding=UTF-8")
- b.RenderPartialTemplate(w, path, vars, true, template.FuncMap{
- "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...)
- },
- })
-
- return nil
-}
diff --git a/core/jsondb/cache.go b/jsondb/cache.go
similarity index 100%
rename from core/jsondb/cache.go
rename to jsondb/cache.go
diff --git a/core/caches/caches.go b/jsondb/caches/caches.go
similarity index 100%
rename from core/caches/caches.go
rename to jsondb/caches/caches.go
diff --git a/core/caches/null/null.go b/jsondb/caches/null/null.go
similarity index 100%
rename from core/caches/null/null.go
rename to jsondb/caches/null/null.go
diff --git a/core/caches/redis/redis.go b/jsondb/caches/redis/redis.go
similarity index 100%
rename from core/caches/redis/redis.go
rename to jsondb/caches/redis/redis.go
diff --git a/core/jsondb/jsondb.go b/jsondb/jsondb.go
similarity index 99%
rename from core/jsondb/jsondb.go
rename to jsondb/jsondb.go
index d74a435..d5712d4 100644
--- a/core/jsondb/jsondb.go
+++ b/jsondb/jsondb.go
@@ -12,7 +12,7 @@ import (
"sync"
"time"
- "github.com/kirsle/blog/core/caches"
+ "github.com/kirsle/blog/jsondb/caches"
)
var (
diff --git a/core/jsondb/log.go b/jsondb/log.go
similarity index 100%
rename from core/jsondb/log.go
rename to jsondb/log.go
diff --git a/root/.errors/400.gohtml b/root/.errors/400.gohtml
index 25842a0..34ad409 100644
--- a/root/.errors/400.gohtml
+++ b/root/.errors/400.gohtml
@@ -2,5 +2,5 @@
{{ define "content" }}
400 Bad Request
-{{ .Message }}
+{{ .Data.Message }}
{{ end }}
diff --git a/root/.errors/403.gohtml b/root/.errors/403.gohtml
index b9832e9..61d91f8 100644
--- a/root/.errors/403.gohtml
+++ b/root/.errors/403.gohtml
@@ -2,5 +2,5 @@
{{ define "content" }}
403 Forbidden
-{{ .Message }}
+{{ .Data.Message }}
{{ end }}
diff --git a/root/.errors/404.gohtml b/root/.errors/404.gohtml
index aab335c..76ad398 100644
--- a/root/.errors/404.gohtml
+++ b/root/.errors/404.gohtml
@@ -2,7 +2,7 @@
{{ define "content" }}
404 Not Found
-{{ .Message }}
+{{ .Data.Message }}
{{ if .CurrentUser.Admin }}
diff --git a/root/.errors/500.gohtml b/root/.errors/500.gohtml
index 2ca412f..9dcea92 100644
--- a/root/.errors/500.gohtml
+++ b/root/.errors/500.gohtml
@@ -2,5 +2,5 @@
{{ define "content" }}
500 Internal Server Error
-{{ .Message }}
+{{ .Data.Message }}
{{ end }}
diff --git a/root/.layout.gohtml b/root/.layout.gohtml
index fa8d95c..4060f56 100644
--- a/root/.layout.gohtml
+++ b/root/.layout.gohtml
@@ -79,9 +79,9 @@
{{ template "content" . }}
- {{ if and .CurrentUser.Admin .Editable }}
+ {{ if and .CurrentUser.Admin .Editable (ne .TemplatePath ".markdown") }}
- Admin: [edit this page]
+ Admin: [edit this page]
{{ end }}
diff --git a/root/.markdown.gohtml b/root/.markdown.gohtml
index 43a7881..99c8972 100644
--- a/root/.markdown.gohtml
+++ b/root/.markdown.gohtml
@@ -3,4 +3,10 @@
{{ .Data.HTML }}
+{{ if and .CurrentUser.Admin .Editable }}
+
+ Admin: [edit this page]
+
+{{ end }}
+
{{ end }}
diff --git a/root/account.gohtml b/root/account.gohtml
index a23df14..be97901 100644
--- a/root/account.gohtml
+++ b/root/account.gohtml
@@ -2,6 +2,7 @@
{{ define "content" }}
Account Settings
+{{ $form := .Data.Form }}