Blog post creation, viewing, index listing, editing, deleting

This commit is contained in:
Noah 2017-11-24 11:56:32 -08:00
parent 5009065480
commit b127c61dd7
25 changed files with 850 additions and 43 deletions

6
TODO.md Normal file
View File

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

View File

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

View File

@ -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, "<snip>") {
parts := strings.SplitN(post.Body, "<snip>", 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, "<snip>") {
log.Warn("HAS SNIP TAG!")
parts := strings.SplitN(p.Body, "<snip>", 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)
}

View File

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

View File

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

View File

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

100
core/models/posts/index.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

23
core/postdb.go Normal file
View File

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

8
core/regexp.go Normal file
View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
{{ define "title" }}Untitled{{ end }}
{{ define "title" }}WTF?{{ end }}
{{ define "scripts" }}{{ end }}
{{ define "layout" }}
@ -8,11 +8,14 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{{ template "title" or "Untitled" }} - {{ .Title }}</title>
<title>{{ template "title" . }} - {{ .Title }}</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="/css/bootstrap.min.css">
<link rel="stylesheet" href="/bluez/theme.css">
<link rel="stylesheet" href="/css/blog-core.css">
<!-- <link rel="stylesheet" href="/css/gfm.css"> -->
</head>
<body>

View File

@ -1,6 +1,7 @@
{{ define "title" }}Website Settings{{ end }}
{{ define "content" }}
<form action="/admin/settings" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<div class="card">
{{ with .Data.s }}
<div class="card-body">

15
root/blog/delete.gohtml Normal file
View File

@ -0,0 +1,15 @@
{{ define "title" }}Delete Entry{{ end }}
{{ define "content" }}
<form action="/blog/delete" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<input type="hidden" name="id" value="{{ .Data.Post.ID }}">
<h1>Delete Post</h1>
<p>Are you sure you want to delete <strong>{{ .Data.Post.Title }}</strong>?</p>
<button type="submit" class="btn btn-primary">Delete Post</button>
<a href="/{{ .Data.Post.Fragment }}" class="btn btn-secondary">Cancel</a>
</form>
{{ end }}

View File

@ -1,9 +1,10 @@
{{ define "title" }}Update Blog{{ end }}
{{ define "content" }}
<form action="/blog/edit" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
{{ if .Data.preview }}
<div class="card">
<div class="card-title">
<div class="card mb-5">
<div class="card-header">
Preview
</div>
<div class="card-body">
@ -13,12 +14,11 @@
{{ end }}
{{ with .Data.post }}
<input type="hidden" name="id" value="{{ .ID }}">
<div class="card">
<div class="card-body">
<h3>Update Blog</h3>
{{ . }}
<div class="form-group">
<label for="title">Title</label>
<input type="text"

27
root/blog/entry.gohtml Normal file
View File

@ -0,0 +1,27 @@
{{ define "title" }}{{ .Data.Post.Title }}{{ end }}
{{ define "content" }}
{{ $p := .Data.Post }}
{{ RenderPost $p false }}
{{ if and .LoggedIn .CurrentUser.Admin }}
<small>
<strong>Admin Actions:</strong>
[
<a href="/blog/edit?id={{ $p.ID }}">Edit</a> |
<a href="/blog/delete?id={{ $p.ID }}">Delete</a>
]
</small>
{{ end }}
{{ if $p.EnableComments }}
<h2 class="mt-4">Comments</h2>
TBD.
{{ else }}
<hr>
<em>Comments are disabled on this post.</em>
{{ end }}
{{ end }}

View File

@ -0,0 +1,41 @@
{{ $a := .Author }}
{{ $p := .Post }}
{{ if .IndexView }}
<a class="h1 blog-title" href="/{{ $p.Fragment }}">{{ $p.Title }}</a>
{{ else }}
<h1 class="blog-title">{{ $p.Title }}</h1>
{{ end }}
<div class="blog-meta">
<span title="{{ $p.Created.Format "Jan 2 2006 @ 15:04:05 MST" }}">
{{ $p.Created.Format "January 2, 2006" }}
</span>
{{ if $p.Updated.After $p.Created }}
<span title="{{ $p.Updated.Format "Jan 2 2006 @ 15:04:05 MST" }}">
(updated {{ $p.Updated.Format "January 2, 2006" }})
</span>
{{ end }}
by <a href="/u/{{ $a.Username }}">{{ or $a.Name $a.Username }}</a>
</div>
<div class="markdown mb-4">
{{ .Rendered }}
{{ if .Snipped }}
<p>
<a href="/{{ $p.Fragment }}#snip">Read more...</a>
</p>
{{ end }}
</div>
{{ if not .IndexView }}<hr>{{ end }}
{{ if $p.Tags }}
<em class="text-muted float-left pr-3">Tags:</em>
<ul class="list-inline">
{{ range $p.Tags }}
<li class="list-inline-item"><a href="/tagged/{{ . }}">{{ . }}</a></li>
{{ end }}
</ul>
{{ end }}

46
root/blog/index.gohtml Normal file
View File

@ -0,0 +1,46 @@
{{ define "title" }}Welcome{{ end }}
{{ define "content" }}
<div class="row">
<div class="col text-right">
<ul class="list-inline">
{{ if .Data.PreviousPage }}
<li class="list-inline-item"><a href="/?page={{ .Data.PreviousPage }}">Earlier</a></li>
{{ end }}
{{ if .Data.NextPage }}
<li class="list-inline-item"><a href="/?page={{ .Data.NextPage }}">Older</a></li>
{{ end }}
</div>
</div>
{{ range .Data.View }}
{{ $p := .Post }}
{{ RenderPost $p true }}
{{ if and $.LoggedIn $.CurrentUser.Admin }}
<div class="mb-4">
<small>
<strong>Admin Actions:</strong>
[
<a href="/blog/edit?id={{ $p.ID }}">Edit</a> |
<a href="/blog/delete?id={{ $p.ID }}">Delete</a>
]
</small>
</div>
{{ end }}
<hr>
{{ end }}
<div class="row">
<div class="col text-right">
<ul class="list-inline">
{{ if .Data.PreviousPage }}
<li class="list-inline-item"><a href="/?page={{ .Data.PreviousPage }}">Earlier</a></li>
{{ end }}
{{ if .Data.NextPage }}
<li class="list-inline-item"><a href="/?page={{ .Data.NextPage }}">Older</a></li>
{{ end }}
</div>
</div>
{{ end }}

24
root/css/blog-core.css Normal file
View File

@ -0,0 +1,24 @@
/*
* Generally useful blog styles.
*/
/* Styles for the blog title <h1> */
.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 <pre> tags */
.markdown code {
display: block;
white-space: pre-line;
}

View File

@ -14,6 +14,7 @@
</p>
<form method="POST" action="/initial-setup">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<div class="form-group">
<label for="setup-admin-username">Admin username:</label>
<input type="text"

View File

@ -3,6 +3,7 @@
<h1>Sign In</h1>
<form name="login" action="/login" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
<div class="row">
<div class="col">
<input type="text" name="username" class="form-control" placeholder="Username">