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\" }}\n

Untitled 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 }}
@@ -14,7 +15,7 @@ class="form-control" name="username" id="username" - value="{{ .Form.Username }}" + value="{{ $form.Username }}" placeholder="soandso"> @@ -24,8 +25,8 @@ class="form-control" name="name" id="name" - value="{{ .Form.Name }}" - placeholder="{{ or .Form.Username "Anonymous" }}"> + value="{{ $form.Name }}" + placeholder="{{ or $form.Username "Anonymous" }}">
@@ -34,7 +35,7 @@ class="form-control" name="email" id="email" - value="{{ .Form.Email }}" + value="{{ $form.Email }}" placeholder="name@domain.com">
diff --git a/root/blog/entry.gohtml b/root/blog/entry.gohtml index 46a094a..410ad65 100644 --- a/root/blog/entry.gohtml +++ b/root/blog/entry.gohtml @@ -2,7 +2,7 @@ {{ define "content" }} {{ $p := .Data.Post }} -{{ RenderPost $p false 0 }} +{{ RenderPost .Request $p false 0 }} {{ if and .LoggedIn .CurrentUser.Admin }} @@ -18,7 +18,7 @@

Comments

{{ $idStr := printf "%d" $p.ID}} - {{ RenderComments $p.Title "post" $idStr }} + {{ RenderComments .Request $p.Title "post" $idStr }} {{ else }}
Comments are disabled on this post. diff --git a/root/blog/entry.partial.gohtml b/root/blog/entry.partial.gohtml index 65a4715..8aadab7 100644 --- a/root/blog/entry.partial.gohtml +++ b/root/blog/entry.partial.gohtml @@ -1,7 +1,8 @@ -{{ $a := .Author }} -{{ $p := .Post }} +{{ $a := .Data.Author }} +{{ $p := .Data.Post }} +{{ $d := .Data }} -{{ if .IndexView }} +{{ if $d.IndexView }} {{ $p.Title }} {{ else }}

{{ $p.Title }}

@@ -27,9 +28,9 @@
- {{ .Rendered }} + {{ $d.Rendered }} - {{ if .Snipped }} + {{ if $d.Snipped }}

Read more...

@@ -45,9 +46,9 @@ {{ end }} -{{ if .IndexView }} +{{ if $d.IndexView }} - {{ .NumComments }} comment{{ if ne .NumComments 1 }}s{{ end }} + {{ $d.NumComments }} comment{{ if ne $d.NumComments 1 }}s{{ end }} | Permalink diff --git a/root/blog/index.partial.gohtml b/root/blog/index.partial.gohtml index ecf161b..0782916 100644 --- a/root/blog/index.partial.gohtml +++ b/root/blog/index.partial.gohtml @@ -1,18 +1,23 @@ +{{ $PreviousPage := .Data.PreviousPage }} +{{ $NextPage := .Data.NextPage }} +{{ $View := .Data.View }}
    - {{ if .PreviousPage }} -
  • Earlier
  • + {{ if $PreviousPage }} +
  • Earlier
  • {{ end }} - {{ if .NextPage }} -
  • Older
  • + {{ if $NextPage }} +
  • Older
  • {{ end }}
-{{ range .View }} +{{ $r := .Request }} + +{{ range $View }} {{ $p := .Post }} - {{ RenderPost $p true .NumComments }} + {{ RenderPost $r $p true .NumComments }} {{ if and $.LoggedIn $.CurrentUser.Admin }}
@@ -31,11 +36,11 @@
    - {{ if .PreviousPage }} -
  • Earlier
  • + {{ if $PreviousPage }} +
  • Earlier
  • {{ end }} - {{ if .NextPage }} -
  • Older
  • + {{ if $NextPage }} +
  • Older
  • {{ end }}
diff --git a/root/blog/tags.partial.gohtml b/root/blog/tags.partial.gohtml index ba0c8fc..5c7fecf 100644 --- a/root/blog/tags.partial.gohtml +++ b/root/blog/tags.partial.gohtml @@ -1,16 +1,16 @@ -{{ if .IndexView }} +{{ if .Data.IndexView }} Sorted by most frequently used: {{ else }} diff --git a/root/bluez/theme.css b/root/bluez/theme.css index 97e8237..3660175 100644 --- a/root/bluez/theme.css +++ b/root/bluez/theme.css @@ -24,6 +24,18 @@ h6, .h6 { color: #333; } +blockquote { + border-left: 2px solid #FF0000; + padding: 0 10px; + margin: 4px 6px; +} +blockquote blockquote { + border-left-color: #FF9900; +} +blockquote blockquote blockquote { + border-left-color: #CCCC00; +} + /* * Bootstrap tweaks and overrides */ diff --git a/root/comments/entry.partial.gohtml b/root/comments/entry.partial.gohtml index 3521194..05cf2da 100644 --- a/root/comments/entry.partial.gohtml +++ b/root/comments/entry.partial.gohtml @@ -101,7 +101,7 @@ class="form-control">{{ .Body }} You may format your message using - Markdown + GitHub Flavored Markdown syntax.
diff --git a/root/contact.gohtml b/root/contact.gohtml index a1af15b..13b4dd9 100644 --- a/root/contact.gohtml +++ b/root/contact.gohtml @@ -7,6 +7,7 @@ administrator.

+{{ $form := .Data.Form }}
@@ -19,7 +20,7 @@ class="form-control" id="name" placeholder="Anonymous" - value="{{ .Form.Name }}"> + value="{{ $form.Name }}">
@@ -28,7 +29,7 @@ class="form-control" id="email" placeholder="(if you want a response)" - value="{{ .Form.Email }}"> + value="{{ $form.Email }}">
@@ -50,7 +51,7 @@ name="message" id="message" placeholder="Message" - required>{{ .Form.Message }} + required>{{ $form.Message }}
diff --git a/root/index.gohtml b/root/index.gohtml index fa80aa6..480d97f 100644 --- a/root/index.gohtml +++ b/root/index.gohtml @@ -8,4 +8,5 @@

{{ RenderIndex .Request "" "" }} + {{ end }} diff --git a/root/initial-setup.gohtml b/root/initial-setup.gohtml index 27aee3e..e2bbb5e 100644 --- a/root/initial-setup.gohtml +++ b/root/initial-setup.gohtml @@ -13,6 +13,7 @@ predictable for an attacker to guess.

+{{ $form := .Data.Form }}
@@ -22,7 +23,7 @@ class="form-control" id="setup-admin-username" placeholder="Enter username" - value="{{ .Form.Username }}"> + value="{{ $form.Username }}">
diff --git a/root/markdown.gohtml b/root/markdown.gohtml new file mode 100644 index 0000000..20be153 --- /dev/null +++ b/root/markdown.gohtml @@ -0,0 +1,712 @@ +{{ define "title" }}Markdown Cheatsheet{{ end }} +{{ define "content" }} + +

Markdown Cheatsheet

+ +

This is a simple reference sheet for Markdown syntax. The de facto place to find Markdown syntax is at +https://daringfireball.net/projects/markdown/syntax +but the examples here are more nicely presented.

+ +

This page just serves as a cheat sheet for Markdown syntax and their results. For descriptive paragraphs +explaining the syntax, see the page linked above.

+ +

This website uses GitHub Flavored Markdown, an +extension of Markdown that supports fenced code blocks, tables, and other features.

+ + + +

Block Elements

+ + +

Paragraphs and Line Breaks

+ +

A paragraph is defined as a group of lines of text separated from other groups +by at least one blank line. A hard return inside a paragraph doesn't get rendered +in the output.

+ +

Headers

+ +

There are two methods to declare a header in Markdown: "underline" it by +writing === or --- on the line directly below the +heading (for <h1> and <h2>, respectively), +or by prefixing the heading with # symbols. Usually the latter +option is the easiest, and you can get more levels of headers this way.

+ + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + This is an H1
+ =============

+ + This is an H2
+ ------------- +
+
+

This is an H1

+

This is an H2

+
+ + # This is an H1
+ ## This is an H2
+ #### This is an H4 +
+
+

This is an H1

+

This is an H2

+

This is an H4

+
+ +

Blockquotes

+ +

Prefix a line of text with > to "quote" it -- like in +"e-mail syntax."

+ +

You may have multiple layers of quotes by using multiple > +symbols.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,
+ > consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.
+ > Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.
+ >
+ > Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
+ > id sem consectetuer libero luctus adipiscing. +
+
+
+

This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, + consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. + Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.

+ +

Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse + id sem consectetuer libero luctus adipiscing.

+
+
+ + > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,
+ consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.
+ Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.

+ + > Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
+ id sem consectetuer libero luctus adipiscing. +
+
+
+

This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, + consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. + Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.

+ +

Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse + id sem consectetuer libero luctus adipiscing.

+
+
+ + > This is the first level of quoting.
+ >
+ >> This is nested blockquote.
+ >>> A third level.
+ >
+ > Back to the first level. +
+
+
+ This is the first level of quoting. +
+ This is nested blockquote. +
+ A third level. +
+
+ Back to the first level. +
+
+ + > ## This is a header.
+ >
+ > 1. This is the first list item.
+ > 2. This is the second list item.
+ >
+ >Here's some example code:
+ >
+ >    return shell_exec("echo $input | $markdown_script"); +
+
+
+

This is a header.

+
    +
  1. This is the first list item.
  2. +
  3. This is the second list item.
  4. +
+ Here's some example code: +
return shell_exec("echo $input | $markdown_script");
+
+
+ + +

Lists

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + * Red
+ * Green
+ * Blue +
+
+
    +
  • Red
  • +
  • Green
  • +
  • Blue
  • +
+
+ + + Red
+ + Green
+ + Blue +
+
+
    +
  • Red
  • +
  • Green
  • +
  • Blue
  • +
+
+ + - Red
+ - Green
+ - Blue +
+
+
    +
  • Red
  • +
  • Green
  • +
  • Blue
  • +
+
+ + 1. Bird
+ 2. McHale
+ 3. Parish +
+
+
    +
  1. Bird
  2. +
  3. McHale
  4. +
  5. Parish
  6. +
+
+ + 1.  This is a list item with two paragraphs. Lorem ipsum dolor
+     sit amet, consectetuer adipiscing elit. Aliquam hendrerit
+     mi posuere lectus.

+ +     Vestibulum enim wisi, viverra nec, fringilla in, laoreet
+     vitae, risus. Donec sit amet nisl. Aliquam semper ipsum
+     sit amet velit.

+ + 2.  Suspendisse id sem consectetuer libero luctus adipiscing. + +

+
    +
  1. This is a list item with two paragraphs. Lorem ipsum dolor + sit amet, consectetuer adipiscing elit. Aliquam hendrerit + mi posuere lectus.

    + + Vestibulum enim wisi, viverra nec, fringilla in, laoreet + vitae, risus. Donec sit amet nisl. Aliquam semper ipsum + sit amet velit.

  2. + +
  3. Suspendisse id sem consectetuer libero luctus adipiscing.
  4. +
+
+ + +

Code Blocks

+ +The typical Markdown way to write a code block is to indent each line of a paragraph with at +least 4 spaces or 1 tab character. The Rophako CMS also uses GitHub-style code blocks, where +you can use three backticks before and after the code block and then you don't need to indent +each line of the code (makes copying/pasting easier!)

+ +Like GitHub-flavored Markdown, with a fenced code block you can also specify a programming +language to get syntax highlighting for the code.

+ + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + This is a normal paragraph.

+ +     This is a code block. + +

+ This is a normal paragraph.

+ +

This is a code block
+
+ + This is a normal paragraph.

+ + ```
+ This is a GitHub style "fenced code block".
+ ``` +
+

+ This is a normal paragraph.

+ +

This is a GitHub style "fenced code block".
+
+ + ```javascript
+ document.writeln("Hello world.");
+ ``` +
+
+
document.writeln("Hello world.");
+
+ + +

Horizontal Rules

+ + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + * * *

+ ***

+ *****

+ - - -

+ --------------------------- + +

+

+


+


+


+


+
+ + + +

Span Elements

+ + +

Links

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + This is [an example](http://example.com/ "Title") inline link.

+ [This link](http://example.net/) has no title attribute. + +

+ This is an example inline link.

+ This link has no title attribute. +

+ + See my [About](/about) page for details. + + + See my About page for details. +
+ + This is [an example][id] reference-style link.

+ [id]: http://example.com/ "Optional Title Here" + +

+ This is an example reference-style link. +
+ + This is an example of an implicit reference-style link: search [Google][] for more.

+ [Google]: http://google.com/ + +

+ This is an example of an implicit reference-style link: search Google for more. +
+ + I get 10 times more traffic from [Google] [1] than from
+ [Yahoo] [2] or [Bing] [3].

+ + [1]: http://google.com/ "Google"
+ [2]: http://search.yahoo.com/ "Yahoo Search"
+ [3]: http://bing.com/ "Bing" +
+

+ I get 10 times more traffic from Google than from + Yahoo or + Bing. +
+ + +

Emphasis

+ + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + *single asterisks*

+ _single underscores_

+ **double asterisks**

+ __double underscores__ + +

+ single asterisks

+ single underscores

+ double asterisks

+ double underscores +

+ + un*frigging*believable + + + unfriggingbelievable +
+ + \*this text is surrounded by literal asterisks\* + + + *this text is surrounded by literal asterisks* +
+ + +

Code

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ + Use the `printf()` function. + + + Use the printf() function. +
+ + ``There is a literal backtick (`) here.`` + + + There is a literal backtick (`) here. +
+ + A single backtick in a code span: `` ` ``

+ A backtick-delimited string in a code span: `` `foo` `` + +

+ A single backtick in a code span: `

+ A backtick-delimited string in a code span: `foo` +

+ Please don't use any `<blink>` tags. + + Please don't use any <blink> tags. +
+ `&#8212;` is the decimal-encoded equivalent of `&mdash;`. + + &#8212; is the decimal-encoded equivalent of + &mdash;. +
+ + +

Images

+ + + + + + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ ![Alt text](/static/avatars/default.png) + + Alt text +
+ ![Alt text](/static/avatars/default.png "Optional title") + + Alt text +
+ + ![Alt text][id]

+ [id]: /static/avatars/default.png "Optional title attribute" + +

+ Alt text +
+ + +

Miscellaneous

+ + +

Automatic Links

+ +E-mail links get automatically converted into a random mess of HTML attributes to +attempt to thwart e-mail harvesting spam bots.

+ + + + + + + + + + + + + + + + + + +
Markdown SyntaxOutput
+ <http://example.com/> + + http://example.com/ +
+ <address@example.com> + + address@example.com

+ + (Source: <a href="&#109;&#97;&#105;&#108;&#116;&#111;&#58; &#97;&#100;&#100;&#114;&#101;&#115;&#115;&#64; &#101;&#120;&#97;&#109;&#112;&#108; &#101;&#46;&#99;&#111;&#109;">&#97; &#100;&#100;&#114;&#101;&#115;&#115; &#64;&#101;&#120;&#97;&#109;&#112; &#108;&#101;&#46;&#99;&#111; &#109;</a>) +

+ + +

Backslash Escapes

+ +Use backslash characters to escape any other special characters in the Markdown syntax. For example, +\* to insert a literal asterisk so that it doesn't get mistaken for e.g. emphasized text, +a list item, etc.

+ +Markdown provides backslash escapes for the following characters:

+ +

\   backslash
+`   backtick
+*   asterisk
+_   underscore
+{}  curly braces
+[]  square brackets
+()  parenthesis
+#   hash mark
++   plus sign
+-   minus sign (hyphen)
+.   dot
+!   exclamation mark
+ +{{ end }}