Merge pull request #4 from kirsle/refactor

Refactor the codebase
This commit is contained in:
Noah 2018-02-10 15:50:52 -08:00 committed by GitHub
commit f702ad12e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 2708 additions and 1742 deletions

View File

@ -10,7 +10,7 @@ import (
"os" "os"
"github.com/kirsle/blog/core" "github.com/kirsle/blog/core"
"github.com/kirsle/blog/core/jsondb" "github.com/kirsle/blog/jsondb"
) )
// Build-time config constants. // Build-time config constants.
@ -51,5 +51,5 @@ func main() {
jsondb.SetDebug(true) jsondb.SetDebug(true)
} }
app.ListenAndServe(fAddress) app.Run(fAddress)
} }

View File

@ -16,7 +16,7 @@ import (
"time" "time"
"github.com/google/uuid" "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/comments"
"github.com/kirsle/blog/core/models/posts" "github.com/kirsle/blog/core/models/posts"
"github.com/kirsle/golog" "github.com/kirsle/golog"

View File

@ -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<h1>Untitled Page</h1>\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)
}

View File

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

View File

@ -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, "<snip>") {
post.Body = strings.Split(post.Body, "<snip>")[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, "<snip>") {
parts := strings.SplitN(p.Body, "<snip>", 2)
p.Body = parts[0]
snipped = true
}
}
p.Body = strings.Replace(p.Body, "<snip>", "<div id=\"snip\"></div>", 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)
}

154
core/core.go Normal file
View File

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

61
core/errors.go Normal file
View File

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

View File

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

View File

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

View File

@ -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<h1>Untitled Page</h1>\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)
}

View File

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

View File

@ -1,37 +1,30 @@
package core package authctl
import ( import (
"errors" "errors"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/kirsle/blog/core/forms" "github.com/kirsle/blog/core/internal/forms"
"github.com/kirsle/blog/core/models/users" "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. // Register the initial setup routes.
func (b *Blog) AuthRoutes(r *mux.Router) { func Register(r *mux.Router) {
r.HandleFunc("/login", b.LoginHandler) r.HandleFunc("/login", loginHandler)
r.HandleFunc("/logout", b.LogoutHandler) r.HandleFunc("/logout", logoutHandler)
r.HandleFunc("/account", b.AccountHandler) r.HandleFunc("/account", accountHandler)
} }
// Login logs the browser in as the given user. func loginHandler(w http.ResponseWriter, r *http.Request) {
func (b *Blog) Login(w http.ResponseWriter, r *http.Request, u *users.User) error { vars := map[string]interface{}{
session, err := b.store.Get(r, "session") // TODO session name "Form": forms.Setup{},
if err != nil {
return err
} }
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 var nextURL string
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
@ -39,77 +32,76 @@ func (b *Blog) LoginHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
nextURL = r.URL.Query().Get("next") nextURL = r.URL.Query().Get("next")
} }
vars.Data["NextURL"] = nextURL vars["NextURL"] = nextURL
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
form := &forms.Login{ form := &forms.Login{
Username: r.FormValue("username"), Username: r.FormValue("username"),
Password: r.FormValue("password"), Password: r.FormValue("password"),
} }
vars.Form = form vars["Form"] = form
err := form.Validate() err := form.Validate()
if err != nil { if err != nil {
vars.Error = err vars["Error"] = err
} else { } else {
// Test the login. // Test the login.
user, err := users.CheckAuth(form.Username, form.Password) user, err := users.CheckAuth(form.Username, form.Password)
if err != nil { if err != nil {
vars.Error = errors.New("bad username or password") vars["Error"] = errors.New("bad username or password")
} else { } else {
// Login OK! // Login OK!
b.Flash(w, r, "Login OK!") responses.Flash(w, r, "Login OK!")
b.Login(w, r, user) auth.Login(w, r, user)
// A next URL given? TODO: actually get to work // A next URL given? TODO: actually get to work
log.Info("Redirect after login to: %s", nextURL) log.Info("Redirect after login to: %s", nextURL)
if len(nextURL) > 0 && nextURL[0] == '/' { if len(nextURL) > 0 && nextURL[0] == '/' {
b.Redirect(w, nextURL) responses.Redirect(w, nextURL)
} else { } else {
b.Redirect(w, "/") responses.Redirect(w, "/")
} }
return 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 logoutHandler(w http.ResponseWriter, r *http.Request) {
func (b *Blog) LogoutHandler(w http.ResponseWriter, r *http.Request) { session, _ := sessions.Store.Get(r, "session")
session, _ := b.store.Get(r, "session")
delete(session.Values, "logged-in") delete(session.Values, "logged-in")
delete(session.Values, "user-id") delete(session.Values, "user-id")
session.Save(r, w) session.Save(r, w)
b.Redirect(w, "/") responses.Redirect(w, "/")
} }
// AccountHandler shows the account settings page. func accountHandler(w http.ResponseWriter, r *http.Request) {
func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) { if !auth.LoggedIn(r) {
if !b.LoggedIn(r) { responses.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!")
b.FlashAndRedirect(w, r, "/login?next=/account", "You must be logged in to do that!")
return return
} }
currentUser, err := b.CurrentUser(r) currentUser, err := auth.CurrentUser(r)
if err != nil { 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 return
} }
// Load an editable copy of the user. // Load an editable copy of the user.
user, err := users.Load(currentUser.ID) user, err := users.Load(currentUser.ID)
if err != nil { 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 return
} }
v := NewVars()
form := &forms.Account{ form := &forms.Account{
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
Name: user.Name, Name: user.Name,
} }
v.Form = form v := map[string]interface{}{
"Form": form,
}
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
form.Username = users.Normalize(r.FormValue("username")) 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.NewPassword = r.FormValue("newpassword")
form.NewPassword2 = r.FormValue("newpassword2") form.NewPassword2 = r.FormValue("newpassword2")
if err = form.Validate(); err != nil { if err = form.Validate(); err != nil {
b.Flash(w, r, err.Error()) responses.Flash(w, r, err.Error())
} else { } else {
var ok = true var ok = true
// Validate the username is available. // Validate the username is available.
if form.Username != user.Username { if form.Username != user.Username {
if _, err = users.LoadUsername(form.Username); err == nil { 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 ok = false
} }
} }
@ -135,12 +127,12 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) {
if len(form.OldPassword) > 0 { if len(form.OldPassword) > 0 {
// Validate their old password. // Validate their old password.
if _, err = users.CheckAuth(form.Username, form.OldPassword); err != nil { 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 ok = false
} else { } else {
err = user.SetPassword(form.NewPassword) err = user.SetPassword(form.NewPassword)
if err != nil { if err != nil {
b.Flash(w, r, "Change password error: %s", err) responses.Flash(w, r, "Change password error: %s", err)
ok = false ok = false
} }
} }
@ -153,14 +145,14 @@ func (b *Blog) AccountHandler(w http.ResponseWriter, r *http.Request) {
user.Email = form.Email user.Email = form.Email
err = user.Save() err = user.Save()
if err != nil { if err != nil {
b.Flash(w, r, "Error saving user: %s", err) responses.Flash(w, r, "Error saving user: %s", err)
} else { } else {
b.FlashAndRedirect(w, r, "/account", "Settings saved!") responses.FlashAndRedirect(w, r, "/account", "Settings saved!")
return return
} }
} }
} }
} }
b.RenderTemplate(w, r, "account", v) render.Template(w, r, "account", v)
} }

View File

@ -1,25 +1,34 @@
package core package comments
import ( import (
"bytes" "bytes"
"errors"
"html/template" "html/template"
"net/http" "net/http"
"net/mail"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/sessions" "github.com/kirsle/blog/core/internal/log"
"github.com/kirsle/blog/core/models/comments" "github.com/kirsle/blog/core/internal/mail"
"github.com/kirsle/blog/core/models/users" "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. var badRequest func(http.ResponseWriter, *http.Request, string)
func (b *Blog) CommentRoutes(r *mux.Router) {
r.HandleFunc("/comments", b.CommentHandler) // Register the comment routes to the app.
r.HandleFunc("/comments/subscription", b.SubscriptionHandler) func Register(r *mux.Router) {
r.HandleFunc("/comments/quick-delete", b.QuickDeleteHandler) 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. // 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. // 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, "-") id := strings.Join(ids, "-")
session := sessions.Get(r)
url := r.URL.Path
// Load their cached name and email if they posted a comment before. // Load their cached name and email if they posted a comment before.
name, _ := session.Values["c.name"].(string) name, _ := session.Values["c.name"].(string)
email, _ := session.Values["c.email"].(string) email, _ := session.Values["c.email"].(string)
editToken, _ := session.Values["c.token"].(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. // Check if the user is a logged-in admin, to make all comments editable.
var isAdmin bool var isAdmin bool
@ -62,10 +74,10 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
// Render all the comments in the thread. // Render all the comments in the thread.
userMap := map[int]*users.User{} userMap := map[int]*users.User{}
for _, c := range thread.Comments { 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.ThreadID = thread.ID
c.OriginURL = url c.OriginURL = url
c.CSRF = csrfToken c.CSRF = csrf
// Look up the author username. // Look up the author username.
if c.UserID > 0 { if c.UserID > 0 {
@ -90,14 +102,14 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
} }
// Get the template snippet. // Get the template snippet.
filepath, err := b.ResolvePath("comments/comments.partial") filepath, err := render.ResolvePath("comments/comments.partial")
if err != nil { if err != nil {
log.Error(err.Error()) log.Error(err.Error())
return template.HTML("[error: missing comments/comments.partial]") return template.HTML("[error: missing comments/comments.partial]")
} }
// And the comment view partial. // And the comment view partial.
entryPartial, err := b.ResolvePath("comments/entry.partial") entryPartial, err := render.ResolvePath("comments/entry.partial")
if err != nil { if err != nil {
log.Error(err.Error()) log.Error(err.Error())
return template.HTML("[error: missing comments/entry.partial]") 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, ID: thread.ID,
OriginURL: url, OriginURL: url,
Subject: subject, Subject: subject,
CSRF: csrfToken, CSRF: csrf,
Thread: &thread, Thread: &thread,
NewComment: comments.Comment{ NewComment: comments.Comment{
Name: name, Name: name,
@ -132,22 +144,20 @@ func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject
return template.HTML(output.String()) return template.HTML(output.String())
} }
// CommentHandler handles the /comments URI for previewing and posting. func commentHandler(w http.ResponseWriter, r *http.Request) {
func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
b.BadRequest(w, r, "That method is not allowed.") badRequest(w, r, "That method is not allowed.")
return return
} }
v := NewVars() currentUser, _ := auth.CurrentUser(r)
currentUser, _ := b.CurrentUser(r) editToken := getEditToken(w, r)
editToken := b.GetEditToken(w, r)
submit := r.FormValue("submit") submit := r.FormValue("submit")
// Load the comment data from the form. // Load the comment data from the form.
c := &comments.Comment{} c := &comments.Comment{}
c.ParseForm(r) c.ParseForm(r)
if c.ThreadID == "" { 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 return
} }
@ -168,13 +178,13 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
id := r.FormValue("id") id := r.FormValue("id")
c, err = t.Find(id) c, err = t.Find(id)
if err != nil { if err != nil {
b.FlashAndRedirect(w, r, "/", "That comment was not found.") responses.FlashAndRedirect(w, r, "/", "That comment was not found.")
return return
} }
// Verify they have the matching edit token. Admin users are allowed. // Verify they have the matching edit token. Admin users are allowed.
if c.EditToken != editToken && !currentUser.Admin { 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 return
} }
@ -185,16 +195,18 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
// Are we deleting said post? // Are we deleting said post?
if submit == "confirm-delete" { if submit == "confirm-delete" {
t.Delete(c.ID) t.Delete(c.ID)
b.FlashAndRedirect(w, r, origin, "Comment deleted!") responses.FlashAndRedirect(w, r, origin, "Comment deleted!")
return return
} }
// Cache their name and email in their session. // 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.name"] = c.Name
session.Values["c.email"] = c.Email session.Values["c.email"] = c.Email
session.Save(r, w) session.Save(r, w)
v := map[string]interface{}{}
// Previewing, deleting, or posting? // Previewing, deleting, or posting?
switch submit { switch submit {
case "preview", "delete": case "preview", "delete":
@ -203,10 +215,10 @@ func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) {
c.Email = currentUser.Email c.Email = currentUser.Email
c.LoadAvatar() c.LoadAvatar()
} }
c.HTML = template.HTML(b.RenderMarkdown(c.Body)) c.HTML = template.HTML(markdown.RenderMarkdown(c.Body))
case "post": case "post":
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
v.Error = err v["Error"] = err
} else { } else {
// Store our edit token, if we don't have one. For example, admins // Store our edit token, if we don't have one. For example, admins
// can edit others' comments but should not replace their edit token. // 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. // Append their comment.
err := t.Post(c) err := t.Post(c)
if err != nil { 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 return
} }
b.NotifyComment(c) mail.NotifyComment(c)
// Are they subscribing to future comments? // Are they subscribing to future comments?
if c.Subscribe && len(c.Email) > 0 { if c.Subscribe && len(c.Email) > 0 {
if _, err := mail.ParseAddress(c.Email); err == nil { if _, err := mail.ParseAddress(c.Email); err == nil {
m := comments.LoadMailingList() m := comments.LoadMailingList()
m.Subscribe(t.ID, c.Email) 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 "+ "Comment posted, and you've been subscribed to "+
"future comments on this page.", "future comments on this page.",
) )
return return
} }
} }
b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!") responses.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!")
log.Info("t: %v", t.Comments) log.Info("t: %v", t.Comments)
return return
} }
} }
v.Data["Thread"] = t v["Thread"] = t
v.Data["Comment"] = c v["Comment"] = c
v.Data["Editing"] = c.Editing v["Editing"] = c.Editing
v.Data["Deleting"] = submit == "delete" 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 quickDeleteHandler(w http.ResponseWriter, r *http.Request) {
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) {
thread := r.URL.Query().Get("t") thread := r.URL.Query().Get("t")
token := r.URL.Query().Get("d") token := r.URL.Query().Get("d")
if thread == "" || token == "" { if thread == "" || token == "" {
b.BadRequest(w, r) badRequest(w, r, "Bad Request")
return return
} }
t, err := comments.Load(thread) t, err := comments.Load(thread)
if err != nil { if err != nil {
b.BadRequest(w, r, "Comment thread does not exist.") badRequest(w, r, "Comment thread does not exist.")
return return
} }
@ -306,13 +283,13 @@ func (b *Blog) QuickDeleteHandler(w http.ResponseWriter, r *http.Request) {
t.Delete(c.ID) 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. // allows a user to edit their comment for a short while after they post it.
func (b *Blog) GetEditToken(w http.ResponseWriter, r *http.Request) string { func getEditToken(w http.ResponseWriter, r *http.Request) string {
session := b.Session(r) session := sessions.Get(r)
if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 { if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 {
return token return token
} }

View File

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

View File

@ -1,4 +1,4 @@
package core package contact
import ( import (
"fmt" "fmt"
@ -9,27 +9,32 @@ import (
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/kirsle/blog/core/forms" "github.com/kirsle/blog/core/internal/forms"
"github.com/kirsle/blog/core/models/settings" "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. // Register attaches the contact URL to the app.
func (b *Blog) ContactRoutes(r *mux.Router) { func Register(r *mux.Router) {
r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/contact", func(w http.ResponseWriter, r *http.Request) {
v := NewVars() form := &forms.Contact{}
form := forms.Contact{} v := map[string]interface{}{
v.Form = &form "Form": form,
}
// If there is no site admin, show an error. // If there is no site admin, show an error.
cfg, err := settings.Load() cfg, err := settings.Load()
if err != nil { if err != nil {
b.Error(w, r, "Error loading site configuration!") responses.Error(w, r, "Error loading site configuration!")
return return
} else if cfg.Site.AdminEmail == "" { } 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 return
} else if !cfg.Mail.Enabled { } 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 return
} }
@ -37,9 +42,9 @@ func (b *Blog) ContactRoutes(r *mux.Router) {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
form.ParseForm(r) form.ParseForm(r)
if err = form.Validate(); err != nil { if err = form.Validate(); err != nil {
b.Flash(w, r, err.Error()) responses.Flash(w, r, err.Error())
} else { } else {
go b.SendEmail(Email{ go mail.SendEmail(mail.Email{
To: cfg.Site.AdminEmail, To: cfg.Site.AdminEmail,
Admin: true, Admin: true,
ReplyTo: form.Email, ReplyTo: form.Email,
@ -47,15 +52,15 @@ func (b *Blog) ContactRoutes(r *mux.Router) {
Template: ".email/contact.gohtml", Template: ".email/contact.gohtml",
Data: map[string]interface{}{ Data: map[string]interface{}{
"Name": form.Name, "Name": form.Name,
"Message": template.HTML(b.RenderMarkdown(form.Message)), "Message": template.HTML(markdown.RenderMarkdown(form.Message)),
"Email": form.Email, "Email": form.Email,
}, },
}) })
// Log it to disk, too. // 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 { 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 { } else {
fh.WriteString(fmt.Sprintf( fh.WriteString(fmt.Sprintf(
"Date: %s\nName: %s\nEmail: %s\nSubject: %s\n\n%s\n\n--------------------\n\n", "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() 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)
}) })
} }

View File

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

View File

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

View File

@ -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, "<snip>") {
post.Body = strings.Split(post.Body, "<snip>")[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))
}
}

View File

@ -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,
})
}

View File

@ -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, "<snip>") {
parts := strings.SplitN(p.Body, "<snip>", 2)
p.Body = parts[0]
snipped = true
}
}
p.Body = strings.Replace(p.Body, "<snip>", "<div id=\"snip\"></div>", 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())
}

View File

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

View File

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

View File

@ -1,6 +1,6 @@
package forms package forms
// Form is an interface for forms that can validate themselves. // Form is an interface for forms that can validate themselves.
type Form interface { // type Form interface {
Validate() error // Validate() error
} // }

View File

@ -2,6 +2,7 @@ package forms
import ( import (
"errors" "errors"
"net/http"
) )
// Setup is for the initial blog setup page at /initial-setup. // Setup is for the initial blog setup page at /initial-setup.
@ -11,6 +12,13 @@ type Setup struct {
Confirm string 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. // Validate the form.
func (f Setup) Validate() error { func (f Setup) Validate() error {
if len(f.Username) == 0 { if len(f.Username) == 0 {

30
core/internal/log/log.go Normal file
View File

@ -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...)
}

View File

@ -1,15 +1,18 @@
package core package mail
import ( import (
"bytes" "bytes"
"crypto/tls"
"fmt" "fmt"
"html/template" "html/template"
"net/mail"
"net/url" "net/url"
"strings" "strings"
"github.com/kirsle/blog/core/models/comments" "github.com/kirsle/blog/core/internal/log"
"github.com/kirsle/blog/core/models/settings" "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" "github.com/microcosm-cc/bluemonday"
gomail "gopkg.in/gomail.v2" gomail "gopkg.in/gomail.v2"
) )
@ -27,7 +30,7 @@ type Email struct {
} }
// SendEmail sends an email. // SendEmail sends an email.
func (b *Blog) SendEmail(email Email) { func SendEmail(email Email) {
s, _ := settings.Load() s, _ := settings.Load()
if !s.Mail.Enabled || s.Mail.Host == "" || s.Mail.Port == 0 || s.Mail.Sender == "" { if !s.Mail.Enabled || s.Mail.Host == "" || s.Mail.Port == 0 || s.Mail.Sender == "" {
log.Info("Suppressing email: not completely configured") log.Info("Suppressing email: not completely configured")
@ -35,7 +38,7 @@ func (b *Blog) SendEmail(email Email) {
} }
// Resolve the template. // Resolve the template.
tmpl, err := b.ResolvePath(email.Template) tmpl, err := render.ResolvePath(email.Template)
if err != nil { if err != nil {
log.Error("SendEmail: %s", err.Error()) log.Error("SendEmail: %s", err.Error())
return return
@ -80,11 +83,6 @@ func (b *Blog) SendEmail(email Email) {
m.AddAlternative("text/html", html.String()) m.AddAlternative("text/html", html.String())
d := gomail.NewDialer(s.Mail.Host, s.Mail.Port, s.Mail.Username, s.Mail.Password) 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) log.Info("SendEmail: %s (%s) to %s", email.Subject, email.Template, email.To)
if err := d.DialAndSend(m); err != nil { if err := d.DialAndSend(m); err != nil {
@ -93,7 +91,7 @@ func (b *Blog) SendEmail(email Email) {
} }
// NotifyComment sends notification emails about comments. // NotifyComment sends notification emails about comments.
func (b *Blog) NotifyComment(c *comments.Comment) { func NotifyComment(c *comments.Comment) {
s, _ := settings.Load() s, _ := settings.Load()
if s.Site.URL == "" { if s.Site.URL == "" {
log.Error("Can't send comment notification because the site URL is not configured") 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{}{ Data: map[string]interface{}{
"Name": c.Name, "Name": c.Name,
"Subject": c.Subject, "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, "URL": strings.Trim(s.Site.URL, "/") + c.OriginURL,
"QuickDelete": fmt.Sprintf("%s/comments/quick-delete?t=%s&d=%s", "QuickDelete": fmt.Sprintf("%s/comments/quick-delete?t=%s&d=%s",
strings.Trim(s.Site.URL, "/"), strings.Trim(s.Site.URL, "/"),
@ -123,7 +121,7 @@ func (b *Blog) NotifyComment(c *comments.Comment) {
email.To = config.Site.AdminEmail email.To = config.Site.AdminEmail
email.Admin = true email.Admin = true
log.Info("Mail site admin '%s' about comment notification on '%s'", email.To, c.ThreadID) log.Info("Mail site admin '%s' about comment notification on '%s'", email.To, c.ThreadID)
b.SendEmail(email) SendEmail(email)
} }
// Email the subscribers. // Email the subscribers.
@ -140,6 +138,11 @@ func (b *Blog) NotifyComment(c *comments.Comment) {
url.QueryEscape(to), url.QueryEscape(to),
) )
log.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.ThreadID) 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)
}

View File

@ -1,4 +1,5 @@
package core // Package markdown implements a GitHub Flavored Markdown renderer.
package markdown
import ( import (
"bytes" "bytes"
@ -10,17 +11,23 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/kirsle/blog/core/internal/log"
"github.com/kirsle/blog/jsondb/caches"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/shurcooL/github_flavored_markdown" "github.com/shurcooL/github_flavored_markdown"
) )
// Regexps for Markdown use cases. // Regexps for Markdown use cases.
var ( var (
// Plug your own Redis cacher in.
Cache caches.Cacher
// Match title from the first `# h1` heading. // Match title from the first `# h1` heading.
reMarkdownTitle = regexp.MustCompile(`(?m:^#([^#\r\n]+)$)`) reMarkdownTitle = regexp.MustCompile(`(?m:^#([^#\r\n]+)$)`)
// Match fenced code blocks with languages defined. // 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. // Regexp to match fenced code blocks in rendered Markdown HTML.
// Tweak this if you change Markdown engines later. // 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 // RenderMarkdown renders markdown to HTML, safely. It uses blackfriday to
// render Markdown to HTML and then Bluemonday to sanitize the resulting HTML. // render Markdown to HTML and then Bluemonday to sanitize the resulting HTML.
func (b *Blog) RenderMarkdown(input string) string { func RenderMarkdown(input string) string {
unsafe := []byte(b.RenderTrustedMarkdown(input)) unsafe := []byte(RenderTrustedMarkdown(input))
// Sanitize HTML, but allow fenced code blocks to not get mangled in user // Sanitize HTML, but allow fenced code blocks to not get mangled in user
// submitted comments. // submitted comments.
@ -65,7 +72,7 @@ func (b *Blog) RenderMarkdown(input string) string {
// RenderTrustedMarkdown renders markdown to HTML, but without applying // RenderTrustedMarkdown renders markdown to HTML, but without applying
// bluemonday filtering afterward. This is for blog posts and website // bluemonday filtering afterward. This is for blog posts and website
// Markdown pages, not for user-submitted comments or things. // Markdown pages, not for user-submitted comments or things.
func (b *Blog) RenderTrustedMarkdown(input string) string { func RenderTrustedMarkdown(input string) string {
// Find and hang on to fenced code blocks. // Find and hang on to fenced code blocks.
codeBlocks := []codeBlock{} codeBlocks := []codeBlock{}
matches := reFencedCode.FindAllStringSubmatch(input, -1) matches := reFencedCode.FindAllStringSubmatch(input, -1)
@ -87,7 +94,10 @@ func (b *Blog) RenderTrustedMarkdown(input string) string {
// Substitute fenced codes back in. // Substitute fenced codes back in.
for _, block := range codeBlocks { 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, html = strings.Replace(html,
fmt.Sprintf("[?FENCED_CODE_%d_BLOCK?]", block.placeholder), fmt.Sprintf("[?FENCED_CODE_%d_BLOCK?]", block.placeholder),
highlighted, highlighted,
@ -105,7 +115,7 @@ func (b *Blog) RenderTrustedMarkdown(input string) string {
// //
// The rendered result is cached in Redis if available, because the CLI // 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. // 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 var result string
// Hash the source for the cache key. // Hash the source for the cache key.
@ -115,9 +125,11 @@ func (b *Blog) Pygmentize(language, source string) (string, error) {
cacheKey := "pygmentize:" + hash cacheKey := "pygmentize:" + hash
// Do we have it cached? // Do we have it cached?
if cached, err := b.Cache.Get(cacheKey); err == nil && len(cached) > 0 { if Cache != nil {
if cached, err := Cache.Get(cacheKey); err == nil && len(cached) > 0 {
return string(cached), nil return string(cached), nil
} }
}
// Defer to the `pygmentize` command // Defer to the `pygmentize` command
bin := "pygmentize" bin := "pygmentize"
@ -140,10 +152,12 @@ func (b *Blog) Pygmentize(language, source string) (string, error) {
} }
result = out.String() result = out.String()
err := b.Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change if Cache != nil {
err := Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change
if err != nil { if err != nil {
log.Error("Couldn't cache Pygmentize output: %s", err) log.Error("Couldn't cache Pygmentize output: %s", err)
} }
}
return result, nil return result, nil
} }

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/kirsle/blog/core/jsondb" "github.com/kirsle/blog/jsondb"
"github.com/kirsle/golog" "github.com/kirsle/golog"
) )

View File

@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/kirsle/blog/core/jsondb" "github.com/kirsle/blog/jsondb"
"github.com/kirsle/golog" "github.com/kirsle/golog"
) )

View File

@ -4,7 +4,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"github.com/kirsle/blog/core/jsondb" "github.com/kirsle/blog/jsondb"
) )
// DB is a reference to the parent app's JsonDB object. // DB is a reference to the parent app's JsonDB object.

View File

@ -6,7 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/kirsle/blog/core/jsondb" "github.com/kirsle/blog/jsondb"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )

View File

@ -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,
}

View File

@ -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")
}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package core package types
// PostPrivacy values. // PostPrivacy values.
type PostPrivacy string type PostPrivacy string
@ -19,3 +19,11 @@ const (
MARKDOWN ContentType = "markdown" MARKDOWN ContentType = "markdown"
HTML ContentType = "html" HTML ContentType = "html"
) )
// Common form actions.
const (
ActionSave = "save"
ActionDelete = "delete"
ActionPreview = "preview"
ActionPost = "post"
)

View File

@ -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.
)

View File

@ -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,
})
}

View File

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

View File

@ -1,13 +1,16 @@
package core package core
import ( import (
"errors"
"html/template" "html/template"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "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. // 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. // Remove trailing slashes by redirecting them away.
if len(path) > 1 && path[len(path)-1] == '/' { if len(path) > 1 && path[len(path)-1] == '/' {
b.Redirect(w, strings.TrimRight(path, "/")) responses.Redirect(w, strings.TrimRight(path, "/"))
return return
} }
// Restrict special paths. // Restrict special paths.
if strings.HasPrefix(strings.ToLower(path), "/.") { if strings.HasPrefix(strings.ToLower(path), "/.") {
b.Forbidden(w, r) responses.Forbidden(w, r, "Forbidden")
return return
} }
// Search for a file that matches their URL. // Search for a file that matches their URL.
filepath, err := b.ResolvePath(path) filepath, err := render.ResolvePath(path)
if err != nil { if err != nil {
// See if it resolves as a blog entry. // 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 { 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 return
} }
// Is it a template file? // Is it a template file?
if strings.HasSuffix(filepath.URI, ".gohtml") { if strings.HasSuffix(filepath.URI, ".gohtml") {
b.RenderTemplate(w, r, filepath.URI, nil) render.Template(w, r, filepath.URI, nil)
return 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") { if strings.HasSuffix(filepath.URI, ".md") || strings.HasSuffix(filepath.URI, ".markdown") {
source, err := ioutil.ReadFile(filepath.Absolute) source, err := ioutil.ReadFile(filepath.Absolute)
if err != nil { if err != nil {
b.Error(w, r, "Couldn't read Markdown source!") responses.Error(w, r, "Couldn't read Markdown source!")
return return
} }
// Render it to HTML and find out its title. // Render it to HTML and find out its title.
body := string(source) body := string(source)
html := b.RenderTrustedMarkdown(body) html := markdown.RenderTrustedMarkdown(body)
title, _ := TitleFromMarkdown(body) title, _ := markdown.TitleFromMarkdown(body)
b.RenderTemplate(w, r, ".markdown", NewVars(map[interface{}]interface{}{ render.Template(w, r, ".markdown", map[string]interface{}{
"Title": title, "Title": title,
"HTML": template.HTML(html), "HTML": template.HTML(html),
"MarkdownFile": filepath.URI, "MarkdownPath": filepath.URI,
})) })
return return
} }
http.ServeFile(w, r, filepath.Absolute) 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")
}

View File

@ -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]+$")
)

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/kirsle/blog/core/caches" "github.com/kirsle/blog/jsondb/caches"
) )
var ( var (

View File

@ -2,5 +2,5 @@
{{ define "content" }} {{ define "content" }}
<h1>400 Bad Request</h1> <h1>400 Bad Request</h1>
{{ .Message }} {{ .Data.Message }}
{{ end }} {{ end }}

View File

@ -2,5 +2,5 @@
{{ define "content" }} {{ define "content" }}
<h1>403 Forbidden</h1> <h1>403 Forbidden</h1>
{{ .Message }} {{ .Data.Message }}
{{ end }} {{ end }}

View File

@ -2,7 +2,7 @@
{{ define "content" }} {{ define "content" }}
<h1>404 Not Found</h1> <h1>404 Not Found</h1>
{{ .Message }} {{ .Data.Message }}
{{ if .CurrentUser.Admin }} {{ if .CurrentUser.Admin }}
<p> <p>

View File

@ -2,5 +2,5 @@
{{ define "content" }} {{ define "content" }}
<h1>500 Internal Server Error</h1> <h1>500 Internal Server Error</h1>
{{ .Message }} {{ .Data.Message }}
{{ end }} {{ end }}

View File

@ -79,9 +79,9 @@
{{ template "content" . }} {{ template "content" . }}
{{ if and .CurrentUser.Admin .Editable }} {{ if and .CurrentUser.Admin .Editable (ne .TemplatePath ".markdown") }}
<p class="mt-4"> <p class="mt-4">
<strong>Admin:</strong> [<a href="/admin/editor?file={{ or .Data.MarkdownFile TemplateName }}">edit this page</a>] <strong>Admin:</strong> [<a href="/admin/editor?file={{ .TemplatePath }}">edit this page</a>]
</p> </p>
{{ end }} {{ end }}
</div> </div>

View File

@ -3,4 +3,10 @@
{{ .Data.HTML }} {{ .Data.HTML }}
{{ if and .CurrentUser.Admin .Editable }}
<p class="mt-4">
<strong>Admin:</strong> [<a href="/admin/editor?file={{ .Data.MarkdownPath }}">edit this page</a>]
</p>
{{ end }}
{{ end }} {{ end }}

View File

@ -2,6 +2,7 @@
{{ define "content" }} {{ define "content" }}
<h1>Account Settings</h1> <h1>Account Settings</h1>
{{ $form := .Data.Form }}
<form action="/account" method="POST"> <form action="/account" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}"> <input type="hidden" name="_csrf" value="{{ .CSRF }}">
@ -14,7 +15,7 @@
class="form-control" class="form-control"
name="username" name="username"
id="username" id="username"
value="{{ .Form.Username }}" value="{{ $form.Username }}"
placeholder="soandso"> placeholder="soandso">
</div> </div>
@ -24,8 +25,8 @@
class="form-control" class="form-control"
name="name" name="name"
id="name" id="name"
value="{{ .Form.Name }}" value="{{ $form.Name }}"
placeholder="{{ or .Form.Username "Anonymous" }}"> placeholder="{{ or $form.Username "Anonymous" }}">
</div> </div>
<div class="form-group"> <div class="form-group">
@ -34,7 +35,7 @@
class="form-control" class="form-control"
name="email" name="email"
id="email" id="email"
value="{{ .Form.Email }}" value="{{ $form.Email }}"
placeholder="name@domain.com"> placeholder="name@domain.com">
</div> </div>

View File

@ -2,7 +2,7 @@
{{ define "content" }} {{ define "content" }}
{{ $p := .Data.Post }} {{ $p := .Data.Post }}
{{ RenderPost $p false 0 }} {{ RenderPost .Request $p false 0 }}
{{ if and .LoggedIn .CurrentUser.Admin }} {{ if and .LoggedIn .CurrentUser.Admin }}
<small> <small>
@ -18,7 +18,7 @@
<h2 id="comments" class="mt-4">Comments</h2> <h2 id="comments" class="mt-4">Comments</h2>
{{ $idStr := printf "%d" $p.ID}} {{ $idStr := printf "%d" $p.ID}}
{{ RenderComments $p.Title "post" $idStr }} {{ RenderComments .Request $p.Title "post" $idStr }}
{{ else }} {{ else }}
<hr> <hr>
<em>Comments are disabled on this post.</em> <em>Comments are disabled on this post.</em>

View File

@ -1,7 +1,8 @@
{{ $a := .Author }} {{ $a := .Data.Author }}
{{ $p := .Post }} {{ $p := .Data.Post }}
{{ $d := .Data }}
{{ if .IndexView }} {{ if $d.IndexView }}
<a class="h1 blog-title" href="/{{ $p.Fragment }}">{{ $p.Title }}</a> <a class="h1 blog-title" href="/{{ $p.Fragment }}">{{ $p.Title }}</a>
{{ else }} {{ else }}
<h1 class="blog-title">{{ $p.Title }}</h1> <h1 class="blog-title">{{ $p.Title }}</h1>
@ -27,9 +28,9 @@
</div> </div>
<div class="markdown mb-4"> <div class="markdown mb-4">
{{ .Rendered }} {{ $d.Rendered }}
{{ if .Snipped }} {{ if $d.Snipped }}
<p> <p>
<a href="/{{ $p.Fragment }}#snip">Read more...</a> <a href="/{{ $p.Fragment }}#snip">Read more...</a>
</p> </p>
@ -45,9 +46,9 @@
</ul> </ul>
{{ end }} {{ end }}
{{ if .IndexView }} {{ if $d.IndexView }}
<em class="text-muted"> <em class="text-muted">
<a href="/{{ $p.Fragment }}#comments">{{ .NumComments }} comment{{ if ne .NumComments 1 }}s{{ end }}</a> <a href="/{{ $p.Fragment }}#comments">{{ $d.NumComments }} comment{{ if ne $d.NumComments 1 }}s{{ end }}</a>
| |
<a href="/{{ $p.Fragment }}">Permalink</a> <a href="/{{ $p.Fragment }}">Permalink</a>
</em> </em>

View File

@ -1,18 +1,23 @@
{{ $PreviousPage := .Data.PreviousPage }}
{{ $NextPage := .Data.NextPage }}
{{ $View := .Data.View }}
<div class="row"> <div class="row">
<div class="col text-right"> <div class="col text-right">
<ul class="list-inline"> <ul class="list-inline">
{{ if .PreviousPage }} {{ if $PreviousPage }}
<li class="list-inline-item"><a href="?page={{ .PreviousPage }}">Earlier</a></li> <li class="list-inline-item"><a href="?page={{ $PreviousPage }}">Earlier</a></li>
{{ end }} {{ end }}
{{ if .NextPage }} {{ if $NextPage }}
<li class="list-inline-item"><a href="?page={{ .NextPage }}">Older</a></li> <li class="list-inline-item"><a href="?page={{ $NextPage }}">Older</a></li>
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ range .View }} {{ $r := .Request }}
{{ range $View }}
{{ $p := .Post }} {{ $p := .Post }}
{{ RenderPost $p true .NumComments }} {{ RenderPost $r $p true .NumComments }}
{{ if and $.LoggedIn $.CurrentUser.Admin }} {{ if and $.LoggedIn $.CurrentUser.Admin }}
<div class="mb-4"> <div class="mb-4">
@ -31,11 +36,11 @@
<div class="row"> <div class="row">
<div class="col text-right"> <div class="col text-right">
<ul class="list-inline"> <ul class="list-inline">
{{ if .PreviousPage }} {{ if $PreviousPage }}
<li class="list-inline-item"><a href="?page={{ .PreviousPage }}">Earlier</a></li> <li class="list-inline-item"><a href="?page={{ $PreviousPage }}">Earlier</a></li>
{{ end }} {{ end }}
{{ if .NextPage }} {{ if $NextPage }}
<li class="list-inline-item"><a href="?page={{ .NextPage }}">Older</a></li> <li class="list-inline-item"><a href="?page={{ $NextPage }}">Older</a></li>
{{ end }} {{ end }}
</div> </div>
</div> </div>

View File

@ -1,16 +1,16 @@
{{ if .IndexView }} {{ if .Data.IndexView }}
Sorted by most frequently used: Sorted by most frequently used:
<ul> <ul>
{{ range .Tags }} {{ range .Data.Tags }}
<li><a href="/tagged/{{ .Name }}">{{ .Name }}</a> ({{ .Count }})</li> <li><a href="/tagged/{{ or .Name "Uncategorized" }}">{{ or .Name "Uncategorized" }}</a> ({{ .Count }})</li>
{{ end }} {{ end }}
</ul> </ul>
{{ else }} {{ else }}
<ul> <ul>
{{ range $i, $t := .Tags }} {{ range $i, $t := .Data.Tags }}
{{ if le $i 20 }} {{ if le $i 20 }}
<li><a href="/tagged/{{ .Name }}">{{ .Name }}</a> ({{ .Count }})</li> <li><a href="/tagged/{{ or .Name "Uncategorized" }}">{{ or .Name "Uncategorized" }}</a> ({{ .Count }})</li>
{{ end }} {{ end }}
{{ end }} {{ end }}
</ul> </ul>

View File

@ -24,6 +24,18 @@ h6, .h6 {
color: #333; 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 * Bootstrap tweaks and overrides
*/ */

View File

@ -101,7 +101,7 @@
class="form-control">{{ .Body }}</textarea> class="form-control">{{ .Body }}</textarea>
<small id="bodyHelp" class="form-text text-muted"> <small id="bodyHelp" class="form-text text-muted">
You may format your message using You may format your message using
<a href="https://daringfireball.net/projects/markdown/syntax">Markdown</a> <a href="/markdown" target="_blank">GitHub Flavored Markdown</a>
syntax. syntax.
</small> </small>
</div> </div>

View File

@ -7,6 +7,7 @@
administrator. administrator.
</p> </p>
{{ $form := .Data.Form }}
<form method="POST" action="/contact"> <form method="POST" action="/contact">
<input type="hidden" name="_csrf" value="{{ .CSRF }}"> <input type="hidden" name="_csrf" value="{{ .CSRF }}">
<div class="form-group"> <div class="form-group">
@ -19,7 +20,7 @@
class="form-control" class="form-control"
id="name" id="name"
placeholder="Anonymous" placeholder="Anonymous"
value="{{ .Form.Name }}"> value="{{ $form.Name }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">Your email:</label> <label for="email">Your email:</label>
@ -28,7 +29,7 @@
class="form-control" class="form-control"
id="email" id="email"
placeholder="(if you want a response)" placeholder="(if you want a response)"
value="{{ .Form.Email }}"> value="{{ $form.Email }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="subject"> <label for="subject">
@ -40,7 +41,7 @@
class="form-control" class="form-control"
id="subject" id="subject"
placeholder="No Subject" placeholder="No Subject"
value="{{ .Form.Subject }}"> value="{{ $form.Subject }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="message">Message:</label> <label for="message">Message:</label>
@ -50,7 +51,7 @@
name="message" name="message"
id="message" id="message"
placeholder="Message" placeholder="Message"
required>{{ .Form.Message }}</textarea> required>{{ $form.Message }}</textarea>
</div> </div>
<button type="submit" class="btn btn-primary">Send Message</button> <button type="submit" class="btn btn-primary">Send Message</button>

View File

@ -8,4 +8,5 @@
</p> </p>
{{ RenderIndex .Request "" "" }} {{ RenderIndex .Request "" "" }}
{{ end }} {{ end }}

View File

@ -13,6 +13,7 @@
predictable for an attacker to guess. predictable for an attacker to guess.
</p> </p>
{{ $form := .Data.Form }}
<form method="POST" action="/initial-setup"> <form method="POST" action="/initial-setup">
<input type="hidden" name="_csrf" value="{{ .CSRF }}"> <input type="hidden" name="_csrf" value="{{ .CSRF }}">
<div class="form-group"> <div class="form-group">
@ -22,7 +23,7 @@
class="form-control" class="form-control"
id="setup-admin-username" id="setup-admin-username"
placeholder="Enter username" placeholder="Enter username"
value="{{ .Form.Username }}"> value="{{ $form.Username }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="setup-admin-password1">Passphrase:</label> <label for="setup-admin-password1">Passphrase:</label>

712
root/markdown.gohtml Normal file
View File

@ -0,0 +1,712 @@
{{ define "title" }}Markdown Cheatsheet{{ end }}
{{ define "content" }}
<h1>Markdown Cheatsheet</h1>
<p>This is a simple reference sheet for Markdown syntax. The de facto place to find Markdown syntax is at
<a href="https://daringfireball.net/projects/markdown/syntax">https://daringfireball.net/projects/markdown/syntax</a>
but the examples here are more nicely presented.</p>
<p>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.</p>
<p>This website uses <a href="https://github.github.com/gfm/">GitHub Flavored Markdown</a>, an
extension of Markdown that supports fenced code blocks, tables, and other features.</p>
<ul>
<li>
<a href="#block">Block Elements</a>
<ul>
<li><a href="#pbr">Paragraphs and Line Breaks</a></li>
<li><a href="#h1">Headers</a></li>
<li><a href="#bq">Blockquotes</a></li>
<li><a href="#ul">Lists</a></li>
<li><a href="#pre">Code Blocks</a></li>
<li><a href="#hr">Horizontal Rules</a></li>
</ul>
</li>
<li>
<a href="#span">Span Elements</a>
<ul>
<li><a href="#a">Links</a></li>
<li><a href="#em">Emphasis</a></li>
<li><a href="#code">Code</a></li>
<li><a href="#img">Images</a></li>
</ul>
</li>
<li>
<a href="#misc">Miscellaneous</a>
<ul>
<li><a href="#autolink">Automatic Links</a></li>
<li><a href="#escape">Backslash Escapes</a></li>
</ul>
</li>
</ul>
<h1 id="block">Block Elements</h1>
<a name="pbr"></a>
<h2>Paragraphs and Line Breaks</h2>
<p>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.</p>
<h2 id="h1">Headers</h2>
<p>There are two methods to declare a header in Markdown: "underline" it by
writing <code>===</code> or <code>---</code> on the line directly below the
heading (for <code>&lt;h1&gt;</code> and <code>&lt;h2&gt;</code>, respectively),
or by prefixing the heading with <code>#</code> symbols. Usually the latter
option is the easiest, and you can get more levels of headers this way.</p>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>
This is an H1<br>
=============<br><br>
This is an H2<br>
-------------
</code>
</td>
<td>
<h1>This is an H1</h1>
<h2>This is an H2</h2>
</td>
</tr>
<tr>
<td>
<code>
# This is an H1<br>
## This is an H2<br>
#### This is an H4
</code>
</td>
<td>
<h1>This is an H1</h1>
<h2>This is an H2</h2>
<h4>This is an H4</h4>
</td>
</tr>
</tbody>
</table>
<h3 id="bq">Blockquotes</h3>
<p>Prefix a line of text with <code>&gt;</code> to "quote" it -- like in
"e-mail syntax."</p>
<p>You may have multiple layers of quotes by using multiple <code>&gt;</code>
symbols.</p>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>
&gt; This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,<br>
&gt; consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.<br>
&gt; Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.<br>
&gt;<br>
&gt; Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse<br>
&gt; id sem consectetuer libero luctus adipiscing.
</code>
</td>
<td>
<blockquote>
<p>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.</p>
<p>Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
id sem consectetuer libero luctus adipiscing.</p>
</blockquote>
</td>
</tr>
<tr>
<td>
<code>
&gt; This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,<br>
consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.<br>
Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.<br><br>
&gt; Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse<br>
id sem consectetuer libero luctus adipiscing.
</code>
</td>
<td>
<blockquote>
<p>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.<p>
<p>Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
id sem consectetuer libero luctus adipiscing.</p>
</blockquote>
</td>
</tr>
<tr>
<td>
<code>
&gt; This is the first level of quoting.<br>
&gt;<br>
&gt;&gt; This is nested blockquote.<br>
&gt;&gt;&gt; A third level.<br>
&gt;<br>
&gt; Back to the first level.
</code>
</td>
<td>
<blockquote>
This is the first level of quoting.
<blockquote>
This is nested blockquote.
<blockquote>
A third level.
</blockquote>
</blockquote>
Back to the first level.
</blockquote>
</td>
</tr>
<tr>
<td>
<code>
&gt; ## This is a header.<br>
&gt;<br>
&gt; 1. This is the first list item.<br>
&gt; 2. This is the second list item.<br>
&gt;<br>
&gt;Here's some example code:<br>
&gt;<br>
&gt;&nbsp;&nbsp;&nbsp;&nbsp;return shell_exec("echo $input | $markdown_script");
</code>
</td>
<td>
<blockquote>
<h2>This is a header.</h2>
<ol>
<li>This is the first list item.</li>
<li>This is the second list item.</li>
</ol>
Here's some example code:
<pre>return shell_exec("echo $input | $markdown_script");</pre>
</blockquote>
</td>
</tr>
</tbody>
</table>
<a name="ul"></a>
<h2>Lists</h2>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>
* Red<br>
* Green<br>
* Blue
</code>
</td>
<td>
<ul>
<li>Red</li>
<li>Green</li>
<li>Blue</li>
</ul>
</td>
</tr>
<tr>
<td>
<code>
+ Red<br>
+ Green<br>
+ Blue
</code>
</td>
<td>
<ul>
<li>Red</li>
<li>Green</li>
<li>Blue</li>
</ul>
</td>
</tr>
<tr>
<td>
<code>
- Red<br>
- Green<br>
- Blue
</code>
</td>
<td>
<ul>
<li>Red</li>
<li>Green</li>
<li>Blue</li>
</ul>
</td>
</tr>
<tr>
<td>
<code>
1. Bird<br>
2. McHale<br>
3. Parish
</code>
</td>
<td>
<ol>
<li>Bird</li>
<li>McHale</li>
<li>Parish</li>
</ol>
</td>
</tr>
<tr>
<td>
<code>
1.&nbsp;&nbsp;This is a list item with two paragraphs. Lorem ipsum dolor<br>
&nbsp;&nbsp;&nbsp;&nbsp;sit amet, consectetuer adipiscing elit. Aliquam hendrerit<br>
&nbsp;&nbsp;&nbsp;&nbsp;mi posuere lectus.<p>
&nbsp;&nbsp;&nbsp;&nbsp;Vestibulum enim wisi, viverra nec, fringilla in, laoreet<br>
&nbsp;&nbsp;&nbsp;&nbsp;vitae, risus. Donec sit amet nisl. Aliquam semper ipsum<br>
&nbsp;&nbsp;&nbsp;&nbsp;sit amet velit.<p>
2.&nbsp;&nbsp;Suspendisse id sem consectetuer libero luctus adipiscing.
</code>
</td>
<td>
<ol>
<li>This is a list item with two paragraphs. Lorem ipsum dolor
sit amet, consectetuer adipiscing elit. Aliquam hendrerit
mi posuere lectus.<p>
Vestibulum enim wisi, viverra nec, fringilla in, laoreet
vitae, risus. Donec sit amet nisl. Aliquam semper ipsum
sit amet velit.</li>
<li>Suspendisse id sem consectetuer libero luctus adipiscing.</li>
</ol>
</td>
</tr>
</tbody>
</table>
<a name="pre"></a>
<h2>Code Blocks</h2>
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!)<p>
Like GitHub-flavored Markdown, with a fenced code block you can also specify a programming
language to get syntax highlighting for the code.<p>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>
This is a normal paragraph.<p>
&nbsp;&nbsp;&nbsp;&nbsp;This is a code block.
</code>
</td>
<td>
This is a normal paragraph.<p>
<pre>This is a code block</pre>
</td>
</tr>
<tr>
<td>
<code>
This is a normal paragraph.<p>
```<br>
This is a GitHub style "fenced code block".<br>
```
</code>
</td>
<td>
This is a normal paragraph.<p>
<pre>This is a GitHub style "fenced code block".</pre>
</td>
</tr>
<tr>
<td>
<code>
```javascript<br>
document.writeln("Hello world.");<br>
```
</code>
</td>
<td>
<div class="codehilite"><pre><span class="nb">document</span><span class="p">.</span><span class="nx">writeln</span><span class="p">(</span><span class="s2">&quot;Hello world.&quot;</span><span class="p">);</span></pre></div>
</td>
</tr>
</tbody>
</table>
<a name="hr"></a>
<h2>Horizontal Rules</h2>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>
* * *<p>
***<p>
*****<p>
- - -<p>
---------------------------
</code>
</td>
<td>
<hr><p>
<hr><p>
<hr><p>
<hr><p>
<hr>
</td>
</tr>
</tbody>
</table>
<a name="span"></a>
<h1>Span Elements</h1>
<a name="a"></a>
<h2>Links</h2>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>
This is [an example](http://example.com/ "Title") inline link.<p>
[This link](http://example.net/) has no title attribute.
</code>
</td>
<td>
This is <a href="http://example.com/" title="Title">an example</a> inline link.<p>
<a href="http://example.net/">This link</a> has no title attribute.
</td>
</tr>
<tr>
<td>
<code>
See my [About](/about) page for details.
</code>
</td>
<td>
See my <a href="/about">About</a> page for details.
</td>
</tr>
<tr>
<td>
<code>
This is [an example][id] reference-style link.<p>
[id]: http://example.com/ "Optional Title Here"
</code>
</td>
<td>
This is <a href="http://example.com/" title="Optional Title Here">an example</a> reference-style link.
</td>
</tr>
<tr>
<td>
<code>
This is an example of an implicit reference-style link: search [Google][] for more.<p>
[Google]: http://google.com/
</code>
</td>
<td>
This is an example of an implicit reference-style link: search <a href="http://google.com/">Google</a> for more.
</td>
</tr>
<tr>
<td>
<code>
I get 10 times more traffic from [Google] [1] than from<br>
[Yahoo] [2] or [Bing] [3].<p>
[1]: http://google.com/ "Google"<br>
[2]: http://search.yahoo.com/ "Yahoo Search"<br>
[3]: http://bing.com/ "Bing"
</code>
</td>
<td>
I get 10 times more traffic from <a href="http://google.com/" title="Google">Google</a> than from
<a href="http://search.yahoo.com/" title="Yahoo Search">Yahoo</a> or
<a href="http://bing.com/" title="Bing">Bing</a>.
</td>
</tr>
</tbody>
</table>
<a name="em"></a>
<h2>Emphasis</h2>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>
*single asterisks*<p>
_single underscores_<p>
**double asterisks**<p>
__double underscores__
</code>
</td>
<td>
<em>single asterisks</em><p>
<em>single underscores</em><p>
<strong>double asterisks</strong><p>
<strong>double underscores</strong>
</td>
</tr>
<tr>
<td>
<code>
un*frigging*believable
</code>
</td>
<td>
un<em>frigging</em>believable
</td>
</tr>
<tr>
<td>
<code>
\*this text is surrounded by literal asterisks\*
</code>
</td>
<td>
*this text is surrounded by literal asterisks*
</td>
</tr>
</tbody>
</table>
<a name="code"></a>
<h2>Code</h2>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>
Use the `printf()` function.
</code>
</td>
<td>
Use the <code>printf()</code> function.
</td>
</tr>
<tr>
<td>
<code>
``There is a literal backtick (`) here.``
</code>
</td>
<td>
<code>There is a literal backtick (`) here.</code>
</td>
</tr>
<tr>
<td>
<code>
A single backtick in a code span: `` ` ``<p>
A backtick-delimited string in a code span: `` `foo` ``
</code>
</td>
<td>
A single backtick in a code span: <code>`</code><p>
A backtick-delimited string in a code span: <code>`foo`</code>
</td>
</tr>
<tr>
<td>
<code>Please don't use any `&lt;blink&gt;` tags.</code>
</td>
<td>
Please don't use any <code>&lt;blink&gt;</code> tags.
</td>
</tr>
<tr>
<td>
<code>`&amp;#8212;` is the decimal-encoded equivalent of `&amp;mdash;`.</code>
</td>
<td>
<code>&amp;#8212;</code> is the decimal-encoded equivalent of
<code>&amp;mdash;</code>.
</td>
</tr>
</tbody>
</table>
<a name="img"></a>
<h2>Images</h2>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>![Alt text](/static/avatars/default.png)</code>
</td>
<td>
<img src="/static/avatars/default.png" alt="Alt text">
</td>
</tr>
<tr>
<td>
<code>![Alt text](/static/avatars/default.png "Optional title")</code>
</td>
<td>
<img src="/static/avatars/default.png" alt="Alt text" title="Optional title">
</td>
</tr>
<tr>
<td>
<code>
![Alt text][id]<p>
[id]: /static/avatars/default.png "Optional title attribute"
</code>
</td>
<td>
<img src="/static/avatars/default.png" alt="Alt text" title="Optional title attribute">
</td>
</tr>
</tbody>
</table>
<a name="misc"></a>
<h1>Miscellaneous</h1>
<a name="autolink"></a>
<h2>Automatic Links</h2>
E-mail links get automatically converted into a random mess of HTML attributes to
attempt to thwart e-mail harvesting spam bots.<p>
<table width="100%" class="table">
<thead>
<tr>
<th width="50%">Markdown Syntax</th>
<th width="50%">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>&lt;http://example.com/&gt;</code>
</td>
<td>
<a href="http://example.com/">http://example.com/</a>
</td>
</tr>
<tr>
<td>
<code>&lt;address@example.com&gt;</code>
</td>
<td>
<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><p>
(Source: <code>&lt;a href="&amp;#109;&amp;#97;&amp;#105;&amp;#108;&amp;#116;&amp;#111;&amp;#58; &amp;#97;&amp;#100;&amp;#100;&amp;#114;&amp;#101;&amp;#115;&amp;#115;&amp;#64; &amp;#101;&amp;#120;&amp;#97;&amp;#109;&amp;#112;&amp;#108; &amp;#101;&amp;#46;&amp;#99;&amp;#111;&amp;#109;"&gt;&amp;#97; &amp;#100;&amp;#100;&amp;#114;&amp;#101;&amp;#115;&amp;#115; &amp;#64;&amp;#101;&amp;#120;&amp;#97;&amp;#109;&amp;#112; &amp;#108;&amp;#101;&amp;#46;&amp;#99;&amp;#111; &amp;#109;&lt;/a&gt;</code>)
</td>
</tr>
</tbody>
</table>
<a name="escape"></a>
<h2>Backslash Escapes</h2>
Use backslash characters to escape any other special characters in the Markdown syntax. For example,
<code>\*</code> to insert a literal asterisk so that it doesn't get mistaken for e.g. emphasized text,
a list item, etc.<p>
Markdown provides backslash escapes for the following characters:<p>
<pre>\ backslash
` backtick
* asterisk
_ underscore
{} curly braces
[] square brackets
() parenthesis
# hash mark
+ plus sign
- minus sign (hyphen)
. dot
! exclamation mark</pre>
{{ end }}