diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..ca30519 --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +# To Do + +These aren't high priority but are needed to get this blog on par with Rophako: + +* [ ] On a single blog entry view page, show links to the previous and next + blog entry in the header and footer. diff --git a/core/app.go b/core/app.go index d346e52..287ba52 100644 --- a/core/app.go +++ b/core/app.go @@ -7,8 +7,10 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/kirsle/blog/core/jsondb" + "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" ) @@ -23,6 +25,9 @@ type Blog struct { DB *jsondb.DB + // Helper singletone + Posts *PostHelper + // Web app objects. n *negroni.Negroni // Negroni middleware manager r *mux.Router // Router @@ -36,6 +41,7 @@ func New(documentRoot, userRoot string) *Blog { UserRoot: userRoot, DB: jsondb.New(filepath.Join(userRoot, ".private")), } + blog.Posts = InitPostHelper(blog) // Load the site config, or start with defaults if not found. settings.DB = blog.DB @@ -49,6 +55,7 @@ func New(documentRoot, userRoot string) *Blog { users.HashCost = config.Security.HashCost // Initialize the rest of the models. + posts.DB = blog.DB users.DB = blog.DB // Initialize the router. @@ -59,6 +66,9 @@ func New(documentRoot, userRoot string) *Blog { blog.AdminRoutes(r) blog.BlogRoutes(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) @@ -66,6 +76,7 @@ func New(documentRoot, userRoot string) *Blog { negroni.NewRecovery(), negroni.NewLogger(), negroni.HandlerFunc(blog.SessionLoader), + negroni.HandlerFunc(blog.CSRFMiddleware), negroni.HandlerFunc(blog.AuthMiddleware), ) n.UseHandler(r) diff --git a/core/blog.go b/core/blog.go index 65f6dbc..f14690c 100644 --- a/core/blog.go +++ b/core/blog.go @@ -1,19 +1,38 @@ package core import ( + "bytes" + "errors" "html/template" "net/http" + "sort" + "strconv" + "strings" "github.com/gorilla/mux" "github.com/kirsle/blog/core/models/posts" + "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 + IndexView bool + Snipped bool +} + // BlogRoutes attaches the blog routes to the app. func (b *Blog) BlogRoutes(r *mux.Router) { + // Public routes + r.HandleFunc("/blog", b.BlogIndex) + // Login-required routers. loginRouter := mux.NewRouter() loginRouter.HandleFunc("/blog/edit", b.EditBlog) + loginRouter.HandleFunc("/blog/delete", b.DeletePost) r.PathPrefix("/blog").Handler( negroni.New( negroni.HandlerFunc(b.LoginRequired), @@ -31,24 +50,208 @@ func (b *Blog) BlogRoutes(r *mux.Router) { )) } +// BlogIndex renders the main index page of the blog. +func (b *Blog) BlogIndex(w http.ResponseWriter, r *http.Request) { + v := NewVars(map[interface{}]interface{}{}) + + // Get the blog index. + idx, _ := posts.GetIndex() + + // The set of blog posts to show. + var pool []posts.Post + for _, post := range idx.Posts { + pool = append(pool, post) + } + + sort.Sort(sort.Reverse(posts.ByUpdated(pool))) + + // 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. + v.Data["Page"] = page + if page > 1 { + v.Data["PreviousPage"] = page - 1 + } else { + v.Data["PreviousPage"] = 0 + } + if offset+perPage < len(pool) { + v.Data["NextPage"] = page + 1 + } else { + v.Data["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 + } + var rendered template.HTML + + // Body has a snipped section? + if strings.Contains(post.Body, "") { + parts := strings.SplitN(post.Body, "", 1) + post.Body = parts[0] + } + + // Render the post. + if post.ContentType == "markdown" { + rendered = template.HTML(b.RenderTrustedMarkdown(post.Body)) + } else { + rendered = template.HTML(post.Body) + } + + // 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() + } + + view = append(view, PostMeta{ + Post: post, + Rendered: rendered, + Author: author, + }) + } + + v.Data["View"] = view + b.RenderTemplate(w, r, "blog/index", 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. +func (b *Blog) viewPost(w http.ResponseWriter, r *http.Request, fragment string) error { + post, err := posts.LoadFragment(fragment) + if err != nil { + return err + } + + 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) 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, "") { + log.Warn("HAS SNIP TAG!") + parts := strings.SplitN(p.Body, "", 2) + p.Body = parts[0] + snipped = true + } + } + + // Render the post to HTML. + var rendered template.HTML + if p.ContentType == "markdown" { + rendered = template.HTML(b.RenderTrustedMarkdown(p.Body)) + } else { + rendered = template.HTML(p.Body) + } + + // Get the template snippet. + filepath, err := b.ResolvePath("blog/entry.partial") + if err != nil { + log.Error(err.Error()) + return "[error: missing blog/entry.partial]" + } + t := template.New("entry.partial.gohtml") + t, err = t.ParseFiles(filepath.Absolute) + if err != nil { + log.Error("Failed to parse entry.partial: %s", err.Error()) + return "[error parsing template in blog/entry.partial]" + } + + meta := PostMeta{ + Post: p, + Rendered: rendered, + Author: author, + IndexView: indexView, + Snipped: snipped, + } + output := bytes.Buffer{} + err = t.Execute(&output, meta) + if err != nil { + log.Error(err.Error()) + return "[error executing template in blog/entry.partial]" + } + + 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": "", }) - post := posts.New() + var post *posts.Post + + // Are we editing an existing post? + if idStr := r.URL.Query().Get("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.LoadForm(r) + post.ParseForm(r) // Previewing, or submitting? switch r.FormValue("submit") { case "preview": - v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body)) - case "submit": + if post.ContentType == "markdown" || post.ContentType == "markdown+html" { + v.Data["preview"] = template.HTML(b.RenderMarkdown(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 + err = post.Save() + if err != nil { + v.Error = err + } else { + b.Flash(w, r, "Post created!") + b.Redirect(w, "/"+post.Fragment) + } } } } @@ -56,3 +259,41 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { v.Data["post"] = post b.RenderTemplate(w, r, "blog/edit", v) } + +// DeletePost to delete a blog entry. +func (b *Blog) DeletePost(w http.ResponseWriter, r *http.Request) { + var post *posts.Post + v := NewVars(map[interface{}]interface{}{ + "Post": nil, + }) + + var idStr string + if r.Method == http.MethodPost { + idStr = r.FormValue("id") + } else { + idStr = r.URL.Query().Get("id") + } + if idStr == "" { + b.FlashAndRedirect(w, r, "/admin", "No post ID given for deletion!") + return + } + + // Convert the post ID to an int. + id, err := strconv.Atoi(idStr) + if err == nil { + post, err = posts.Load(id) + if err != nil { + b.FlashAndRedirect(w, r, "/admin", "That post ID was not found.") + return + } + } + + if r.Method == http.MethodPost { + post.Delete() + b.FlashAndRedirect(w, r, "/admin", "Blog entry deleted!") + return + } + + v.Data["Post"] = post + b.RenderTemplate(w, r, "blog/delete", v) +} diff --git a/core/jsondb/jsondb.go b/core/jsondb/jsondb.go index 6e61b38..e0edfec 100644 --- a/core/jsondb/jsondb.go +++ b/core/jsondb/jsondb.go @@ -141,7 +141,7 @@ func (db *DB) list(path string, recursive bool) ([]string, error) { } if strings.HasSuffix(filePath, ".json") { - name := strings.TrimSuffix(filePath, ".json") + name := strings.TrimSuffix(dbPath, ".json") docs = append(docs, name) } } diff --git a/core/markdown.go b/core/markdown.go index f1babfa..de5761a 100644 --- a/core/markdown.go +++ b/core/markdown.go @@ -1,9 +1,28 @@ package core -import "github.com/shurcooL/github_flavored_markdown" +import ( + "github.com/microcosm-cc/bluemonday" + "github.com/shurcooL/github_flavored_markdown" +) -// RenderMarkdown renders markdown to HTML. +// RenderMarkdown renders markdown to HTML, safely. It uses blackfriday to +// render Markdown to HTML and then Bluemonday to sanitize the resulting HTML. func (b *Blog) RenderMarkdown(input string) string { - output := github_flavored_markdown.Markdown([]byte(input)) - return string(output) + unsafe := []byte(b.RenderTrustedMarkdown(input)) + + // Sanitize HTML, but allow fenced code blocks to not get mangled in user + // submitted comments. + p := bluemonday.UGCPolicy() + p.AllowAttrs("class").Matching(reFencedCodeClass).OnElements("code") + html := p.SanitizeBytes(unsafe) + return string(html) +} + +// RenderTrustedMarkdown renders markdown to HTML, but without applying +// bluemonday filtering afterward. This is for blog posts and website +// Markdown pages, not for user-submitted comments or things. +func (b *Blog) RenderTrustedMarkdown(input string) string { + html := github_flavored_markdown.Markdown([]byte(input)) + log.Info("%s", html) + return string(html) } diff --git a/core/middleware.go b/core/middleware.go index 71be2c5..375d3bb 100644 --- a/core/middleware.go +++ b/core/middleware.go @@ -2,8 +2,10 @@ package core import ( "context" + "errors" "net/http" + "github.com/google/uuid" "github.com/gorilla/sessions" "github.com/kirsle/blog/core/models/users" ) @@ -40,25 +42,54 @@ func (b *Blog) Session(r *http.Request) *sessions.Session { return session } -// AuthMiddleware loads the user's authentication state. -func (b *Blog) AuthMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - session := b.Session(r) - log.Debug("AuthMiddleware() -- session values: %v", session.Values) - if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn { - // They seem to be logged in. Get their user object. - id := session.Values["user-id"].(int) - u, err := users.Load(id) - if err != nil { - log.Error("Error loading user ID %d from session: %v", id, err) - next(w, r) +// 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, ok := session.Values["csrf"].(string) + if !ok || token != r.FormValue("_csrf") { + b.Forbidden(w, r, "Failed to validate CSRF token. Please try your request again.") return } + } - ctx := context.WithValue(r.Context(), userKey, u) - next(w, r.WithContext(ctx)) + 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) + return u, err + } + + return &users.User{}, errors.New("not authenticated") +} + +// 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 { + log.Error("Error loading user from session: %v", err) + next(w, r) return } - next(w, r) + + ctx := context.WithValue(r.Context(), userKey, u) + next(w, r.WithContext(ctx)) } // LoginRequired is a middleware that requires a logged-in user. diff --git a/core/models/posts/index.go b/core/models/posts/index.go new file mode 100644 index 0000000..9e0423f --- /dev/null +++ b/core/models/posts/index.go @@ -0,0 +1,100 @@ +package posts + +import "strings" + +// UpdateIndex updates a post's metadata in the blog index. +func UpdateIndex(p *Post) error { + idx, err := GetIndex() + if err != nil { + return err + } + return idx.Update(p) +} + +// Index caches high level metadata about the blog's contents for fast access. +type Index struct { + Posts map[int]Post `json:"posts"` +} + +// GetIndex loads the index, or rebuilds it first if it doesn't exist. +func GetIndex() (*Index, error) { + if !DB.Exists("blog/index") { + index, err := RebuildIndex() + return index, err + } + idx := &Index{} + err := DB.Get("blog/index", &idx) + return idx, err +} + +// RebuildIndex builds the index from scratch. +func RebuildIndex() (*Index, error) { + idx := &Index{ + Posts: map[int]Post{}, + } + entries, _ := DB.List("blog/posts") + for _, doc := range entries { + p := &Post{} + err := DB.Get(doc, &p) + if err != nil { + return nil, err + } + + idx.Update(p) + } + + return idx, nil +} + +// Update a blog's entry in the index. +func (idx *Index) Update(p *Post) error { + idx.Posts[p.ID] = Post{ + ID: p.ID, + Title: p.Title, + Fragment: p.Fragment, + AuthorID: p.AuthorID, + Privacy: p.Privacy, + Tags: p.Tags, + Created: p.Created, + Updated: p.Updated, + } + err := DB.Commit("blog/index", idx) + return err +} + +// Delete a blog's entry from the index. +func (idx *Index) Delete(p *Post) error { + delete(idx.Posts, p.ID) + return DB.Commit("blog/index", idx) +} + +// CleanupFragments to clean up old URL fragments. +func CleanupFragments() error { + idx, err := GetIndex() + if err != nil { + return err + } + return idx.CleanupFragments() +} + +// CleanupFragments to clean up old URL fragments. +func (idx *Index) CleanupFragments() error { + // Keep track of the active URL fragments so we can clean up orphans. + fragments := map[string]struct{}{} + for _, p := range idx.Posts { + fragments[p.Fragment] = struct{}{} + } + + // Clean up unused fragments. + byFragment, err := DB.List("blog/fragments") + for _, doc := range byFragment { + parts := strings.Split(doc, "/") + fragment := parts[len(parts)-1] + if _, ok := fragments[fragment]; !ok { + log.Debug("RebuildIndex() clean up old fragment '%s'", fragment) + DB.Delete(doc) + } + } + + return err +} diff --git a/core/models/posts/posts.go b/core/models/posts/posts.go index 3503fcc..b57ac6e 100644 --- a/core/models/posts/posts.go +++ b/core/models/posts/posts.go @@ -2,22 +2,45 @@ package posts import ( "errors" + "fmt" "net/http" + "regexp" "strconv" "strings" + "time" + + "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/golog" ) +// DB is a reference to the parent app's JsonDB object. +var DB *jsondb.DB + +var log *golog.Logger + +func init() { + log = golog.GetLogger("blog") +} + // Post holds information for a blog post. type Post struct { - ID int `json:"id"` - Title string `json:"title"` - Fragment string `json:"fragment"` - ContentType string `json:"contentType"` - Body string `json:"body"` - Privacy string `json:"privacy"` - Sticky bool `json:"sticky"` - EnableComments bool `json:"enableComments"` - Tags []string `json:"tags"` + ID int `json:"id"` + Title string `json:"title"` + Fragment string `json:"fragment"` + ContentType string `json:"contentType"` + AuthorID int `json:"author"` + Body string `json:"body,omitempty"` + Privacy string `json:"privacy"` + Sticky bool `json:"sticky"` + EnableComments bool `json:"enableComments"` + Tags []string `json:"tags"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// ByFragment maps a blog post by its URL fragment. +type ByFragment struct { + ID int `json:"id"` } // New creates a blank post with sensible defaults. @@ -29,8 +52,8 @@ func New() *Post { } } -// LoadForm populates the post from form values. -func (p *Post) LoadForm(r *http.Request) { +// ParseForm populates the post from form values. +func (p *Post) ParseForm(r *http.Request) { id, _ := strconv.Atoi(r.FormValue("id")) p.ID = id @@ -64,3 +87,139 @@ func (p *Post) Validate() error { } return nil } + +// Load a post by its ID. +func Load(id int) (*Post, error) { + p := &Post{} + err := DB.Get(fmt.Sprintf("blog/posts/%d", id), &p) + return p, err +} + +// LoadFragment loads a blog entry by its URL fragment. +func LoadFragment(fragment string) (*Post, error) { + f := ByFragment{} + err := DB.Get("blog/fragments/"+fragment, &f) + if err != nil { + return nil, err + } + + p, err := Load(f.ID) + return p, err +} + +// Save the blog post. +func (p *Post) Save() error { + // Editing an existing post? + if p.ID == 0 { + p.ID = p.nextID() + } + + // Generate a URL fragment if needed. + if p.Fragment == "" { + fragment := strings.ToLower(p.Title) + fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-") + if strings.Contains(fragment, "--") { + log.Error("Generated blog fragment '%s' contains double dashes still!", fragment) + } + p.Fragment = strings.Trim(fragment, "-") + + // If still no fragment, make one based on the post ID. + if p.Fragment == "" { + p.Fragment = fmt.Sprintf("post-%d", p.ID) + } + } + + // Make sure the URL fragment is unique! + if len(p.Fragment) > 0 { + if exist, err := LoadFragment(p.Fragment); err == nil && exist.ID != p.ID { + var resolved bool + for i := 1; i <= 100; i++ { + fragment := fmt.Sprintf("%s-%d", p.Fragment, i) + _, err := LoadFragment(fragment) + if err == nil { + continue + } + + p.Fragment = fragment + resolved = true + break + } + + if !resolved { + return fmt.Errorf("failed to generate a unique URL fragment for '%s' after 100 attempts", p.Fragment) + } + } + } + + // Dates & times. + if p.Created.IsZero() { + p.Created = time.Now().UTC() + } + if p.Updated.IsZero() { + p.Updated = p.Created + } + + // Empty tag lists. + if len(p.Tags) == 1 && p.Tags[0] == "" { + p.Tags = []string{} + } + + // Write the post. + DB.Commit(fmt.Sprintf("blog/posts/%d", p.ID), p) + DB.Commit(fmt.Sprintf("blog/fragments/%s", p.Fragment), ByFragment{p.ID}) + + // Update the index cache. + err := UpdateIndex(p) + if err != nil { + return fmt.Errorf("RebuildIndex() error: %v", err) + } + + // Clean up fragments. + CleanupFragments() + + return nil +} + +// Delete a blog entry. +func (p *Post) Delete() error { + if p.ID == 0 { + return errors.New("post has no ID") + } + + // Delete the DB files. + DB.Delete(fmt.Sprintf("blog/posts/%d", p.ID)) + DB.Delete(fmt.Sprintf("blog/fragments/%s", p.Fragment)) + + // Remove it from the index. + idx, err := GetIndex() + if err != nil { + return fmt.Errorf("GetIndex error: %v", err) + } + return idx.Delete(p) +} + +// getNextID gets the next blog post ID. +func (p *Post) nextID() int { + // Highest ID seen so far. + var highest int + + posts, err := DB.List("blog/posts") + if err != nil { + return 1 + } + + for _, doc := range posts { + fields := strings.Split(doc, "/") + id, err := strconv.Atoi(fields[len(fields)-1]) + if err != nil { + continue + } + + if id > highest { + highest = id + } + } + + // Return the highest +1 + return highest + 1 +} diff --git a/core/models/posts/sorting.go b/core/models/posts/sorting.go new file mode 100644 index 0000000..b2efb99 --- /dev/null +++ b/core/models/posts/sorting.go @@ -0,0 +1,10 @@ +package posts + +// ByUpdated sorts blog entries by most recently updated. +type ByUpdated []Post + +func (a ByUpdated) Len() int { return len(a) } +func (a ByUpdated) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByUpdated) Less(i, j int) bool { + return a[i].Updated.Before(a[i].Updated) || a[i].ID < a[j].ID +} diff --git a/core/models/users/users.go b/core/models/users/users.go index ae67a9b..31e5981 100644 --- a/core/models/users/users.go +++ b/core/models/users/users.go @@ -25,6 +25,10 @@ type User struct { Admin bool `json:"admin"` Name string `json:"name"` Email string `json:"email"` + + // Whether the user was loaded in read-only mode (no password), so they + // can't be saved without a password. + readonly bool } // ByName model maps usernames to their IDs. @@ -58,6 +62,13 @@ func Create(u *User) error { return u.Save() } +// DeletedUser returns a User object to represent a deleted (non-existing) user. +func DeletedUser() *User { + return &User{ + Username: "[deleted]", + } +} + // CheckAuth tests a login with a username and password. func CheckAuth(username, password string) (*User, error) { username = Normalize(username) @@ -118,8 +129,20 @@ func Load(id int) (*User, error) { return u, err } +// LoadReadonly loads a user for read-only use, so the Password is masked. +func LoadReadonly(id int) (*User, error) { + u, err := Load(id) + u.Password = "" + u.readonly = true + return u, err +} + // Save the user. func (u *User) Save() error { + if u.readonly { + return errors.New("user is read-only") + } + // Sanity check that we have an ID. if u.ID == 0 { return errors.New("can't save a user with no ID") diff --git a/core/pages.go b/core/pages.go index b39844f..484861f 100644 --- a/core/pages.go +++ b/core/pages.go @@ -19,6 +19,12 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { return } + // Handle the root URI with the blog index. + if path == "/" { + b.BlogIndex(w, r) + return + } + // Restrict special paths. if strings.HasPrefix(strings.ToLower(path), "/.") { b.Forbidden(w, r) @@ -28,7 +34,11 @@ func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) { // Search for a file that matches their URL. filepath, err := b.ResolvePath(path) if err != nil { - b.NotFound(w, r, "The page you were looking for was not found.") + // See if it resolves as a blog entry. + err = b.viewPost(w, r, path) + if err != nil { + b.NotFound(w, r, "The page you were looking for was not found.") + } return } diff --git a/core/postdb.go b/core/postdb.go new file mode 100644 index 0000000..508fed7 --- /dev/null +++ b/core/postdb.go @@ -0,0 +1,23 @@ +package core + +import ( + "github.com/kirsle/blog/core/jsondb" +) + +// PostHelper is a singleton helper to manage the database controls for blog +// entries. +type PostHelper struct { + master *Blog + DB *jsondb.DB +} + +// InitPostHelper initializes the blog post controller helper. +func InitPostHelper(master *Blog) *PostHelper { + return &PostHelper{ + master: master, + DB: master.DB, + } +} + +// GetIndex loads the blog index (cache). +func (p *PostHelper) GetIndex() {} diff --git a/core/regexp.go b/core/regexp.go new file mode 100644 index 0000000..3a3ffbe --- /dev/null +++ b/core/regexp.go @@ -0,0 +1,8 @@ +package core + +import "regexp" + +var ( + // CSS classes for Markdown fenced code blocks + reFencedCodeClass = regexp.MustCompile("^highlight highlight-[a-zA-Z0-9]+$") +) diff --git a/core/responses.go b/core/responses.go index 85a51d9..87451fa 100644 --- a/core/responses.go +++ b/core/responses.go @@ -50,9 +50,10 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin // Forbidden sends an HTTP 403 Forbidden response. func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) { - log.Error("HERE 3") w.WriteHeader(http.StatusForbidden) - err := b.RenderTemplate(w, r, ".errors/403", nil) + 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()")) diff --git a/core/templates.go b/core/templates.go index 07f7b8a..24d8901 100644 --- a/core/templates.go +++ b/core/templates.go @@ -20,6 +20,8 @@ type Vars struct { Path string LoggedIn bool CurrentUser *users.User + CSRF string + Request *http.Request // Common template variables. Message string @@ -54,6 +56,7 @@ func (v *Vars) LoadDefaults(b *Blog, w http.ResponseWriter, r *http.Request) { if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup") { v.SetupNeeded = true } + v.Request = r v.Title = s.Site.Title v.Path = r.URL.Path @@ -67,6 +70,8 @@ func (v *Vars) LoadDefaults(b *Blog, w http.ResponseWriter, r *http.Request) { session.Save(r, w) } + v.CSRF = b.GenerateCSRFToken(w, r, session) + ctx := r.Context() if user, ok := ctx.Value(userKey).(*users.User); ok { if user.ID > 0 { @@ -101,6 +106,7 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin log.Error("HERE!!!") t := template.New(filepath.Absolute).Funcs(template.FuncMap{ "StringsJoin": strings.Join, + "RenderPost": b.RenderPost, }) // Parse the template files. The layout comes first because it's the wrapper diff --git a/root/.layout.gohtml b/root/.layout.gohtml index 0241055..3da8880 100644 --- a/root/.layout.gohtml +++ b/root/.layout.gohtml @@ -1,4 +1,4 @@ -{{ define "title" }}Untitled{{ end }} +{{ define "title" }}WTF?{{ end }} {{ define "scripts" }}{{ end }} {{ define "layout" }} @@ -8,11 +8,14 @@ - {{ template "title" or "Untitled" }} - {{ .Title }} + {{ template "title" . }} - {{ .Title }} + + + diff --git a/root/admin/settings.gohtml b/root/admin/settings.gohtml index 2cb5aa8..e4315a3 100644 --- a/root/admin/settings.gohtml +++ b/root/admin/settings.gohtml @@ -1,6 +1,7 @@ {{ define "title" }}Website Settings{{ end }} {{ define "content" }}
+
{{ with .Data.s }}
diff --git a/root/blog/delete.gohtml b/root/blog/delete.gohtml new file mode 100644 index 0000000..1db477d --- /dev/null +++ b/root/blog/delete.gohtml @@ -0,0 +1,15 @@ +{{ define "title" }}Delete Entry{{ end }} +{{ define "content" }} + + + + +

Delete Post

+ +

Are you sure you want to delete {{ .Data.Post.Title }}?

+ + +Cancel + + +{{ end }} diff --git a/root/blog/edit.gohtml b/root/blog/edit.gohtml index 507ec0a..a443a33 100644 --- a/root/blog/edit.gohtml +++ b/root/blog/edit.gohtml @@ -1,9 +1,10 @@ {{ define "title" }}Update Blog{{ end }} {{ define "content" }}
+ {{ if .Data.preview }} -
-
+
+
Preview
@@ -13,12 +14,11 @@ {{ end }} {{ with .Data.post }} +

Update Blog

- {{ . }} -
+ Admin Actions: + [ + Edit | + Delete + ] + +{{ end }} + +{{ if $p.EnableComments }} +

Comments

+ + TBD. +{{ else }} +
+ Comments are disabled on this post. +{{ end }} + + +{{ end }} diff --git a/root/blog/entry.partial.gohtml b/root/blog/entry.partial.gohtml new file mode 100644 index 0000000..4ff0575 --- /dev/null +++ b/root/blog/entry.partial.gohtml @@ -0,0 +1,41 @@ +{{ $a := .Author }} +{{ $p := .Post }} + +{{ if .IndexView }} + {{ $p.Title }} +{{ else }} +

{{ $p.Title }}

+{{ end }} + +
+ + {{ $p.Created.Format "January 2, 2006" }} + + {{ if $p.Updated.After $p.Created }} + + (updated {{ $p.Updated.Format "January 2, 2006" }}) + + {{ end }} + by {{ or $a.Name $a.Username }} +
+ +
+ {{ .Rendered }} + + {{ if .Snipped }} +

+ Read more... +

+ {{ end }} +
+ +{{ if not .IndexView }}
{{ end }} + +{{ if $p.Tags }} + Tags: +
    + {{ range $p.Tags }} +
  • {{ . }}
  • + {{ end }} +
+{{ end }} diff --git a/root/blog/index.gohtml b/root/blog/index.gohtml new file mode 100644 index 0000000..461ae21 --- /dev/null +++ b/root/blog/index.gohtml @@ -0,0 +1,46 @@ +{{ define "title" }}Welcome{{ end }} +{{ define "content" }} + +
+
+
    + {{ if .Data.PreviousPage }} +
  • Earlier
  • + {{ end }} + {{ if .Data.NextPage }} +
  • Older
  • + {{ end }} +
+
+ +{{ range .Data.View }} + {{ $p := .Post }} + {{ RenderPost $p true }} + + {{ if and $.LoggedIn $.CurrentUser.Admin }} +
+ + Admin Actions: + [ + Edit | + Delete + ] + +
+ {{ end }} +
+{{ end }} + +
+
+
    + {{ if .Data.PreviousPage }} +
  • Earlier
  • + {{ end }} + {{ if .Data.NextPage }} +
  • Older
  • + {{ end }} +
+
+ +{{ end }} diff --git a/root/css/blog-core.css b/root/css/blog-core.css new file mode 100644 index 0000000..cace1ba --- /dev/null +++ b/root/css/blog-core.css @@ -0,0 +1,24 @@ +/* + * Generally useful blog styles. + */ + +/* Styles for the blog title

*/ +.blog-title { + display: block; +} +a.blog-title { + text-decoration: underline; +} + +/* The blog entry publish date */ +.blog-meta { + font-style: italic; + font-size: smaller; + margin-bottom: 2rem; +} + +/* Code blocks treated as
 tags */
+.markdown code {
+    display: block;
+    white-space: pre-line;
+}
diff --git a/root/initial-setup.gohtml b/root/initial-setup.gohtml
index efbbdcd..27aee3e 100644
--- a/root/initial-setup.gohtml
+++ b/root/initial-setup.gohtml
@@ -14,6 +14,7 @@
 

+
Sign In

+