25 changed files with 1143 additions and 21 deletions
@ -0,0 +1,227 @@ |
|||
package controllers |
|||
|
|||
import ( |
|||
"bytes" |
|||
"fmt" |
|||
"html/template" |
|||
"net/http" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/authentication" |
|||
"git.kirsle.net/apps/gophertype/pkg/glue" |
|||
"git.kirsle.net/apps/gophertype/pkg/markdown" |
|||
"git.kirsle.net/apps/gophertype/pkg/models" |
|||
"git.kirsle.net/apps/gophertype/pkg/responses" |
|||
"git.kirsle.net/apps/gophertype/pkg/session" |
|||
"git.kirsle.net/apps/gophertype/pkg/settings" |
|||
"github.com/albrow/forms" |
|||
"github.com/gorilla/mux" |
|||
) |
|||
|
|||
func init() { |
|||
glue.Register(glue.Endpoint{ |
|||
Path: "/blog", |
|||
Methods: []string{"GET"}, |
|||
Handler: BlogIndex(models.Public, false), |
|||
}) |
|||
glue.Register(glue.Endpoint{ |
|||
Path: "/tagged/{tag}", |
|||
Methods: []string{"GET"}, |
|||
Handler: BlogIndex(models.Public, true), |
|||
}) |
|||
glue.Register(glue.Endpoint{ |
|||
Path: "/blog/drafts", |
|||
Middleware: []mux.MiddlewareFunc{ |
|||
authentication.LoginRequired, |
|||
}, |
|||
Methods: []string{"GET"}, |
|||
Handler: BlogIndex(models.Draft, false), |
|||
}) |
|||
glue.Register(glue.Endpoint{ |
|||
Path: "/blog/private", |
|||
Middleware: []mux.MiddlewareFunc{ |
|||
authentication.LoginRequired, |
|||
}, |
|||
Methods: []string{"GET"}, |
|||
Handler: BlogIndex(models.Private, false), |
|||
}) |
|||
glue.Register(glue.Endpoint{ |
|||
Path: "/blog/unlisted", |
|||
Middleware: []mux.MiddlewareFunc{ |
|||
authentication.LoginRequired, |
|||
}, |
|||
Methods: []string{"GET"}, |
|||
Handler: BlogIndex(models.Unlisted, false), |
|||
}) |
|||
glue.Register(glue.Endpoint{ |
|||
Path: "/blog/edit", |
|||
Methods: []string{"GET", "POST"}, |
|||
Middleware: []mux.MiddlewareFunc{ |
|||
authentication.LoginRequired, |
|||
}, |
|||
Handler: EditPost, |
|||
}) |
|||
} |
|||
|
|||
// BlogIndex handles all of the top-level blog index routes:
|
|||
// - /blog
|
|||
// - /tagged/{tag}
|
|||
// - /blog/unlisted
|
|||
// - /blog/drafts
|
|||
// - /blog/private
|
|||
func BlogIndex(privacy string, tagged bool) http.HandlerFunc { |
|||
return func(w http.ResponseWriter, r *http.Request) { |
|||
var ( |
|||
v = responses.NewTemplateVars(w, r) |
|||
tagName string |
|||
) |
|||
|
|||
// Tagged view?
|
|||
if tagged { |
|||
params := mux.Vars(r) |
|||
tagName = params["tag"] |
|||
} |
|||
|
|||
// Page title to use.
|
|||
var title = "Blog" |
|||
if tagged { |
|||
title = "Tagged as: " + tagName |
|||
} else if privacy == models.Draft { |
|||
title = "Drafts" |
|||
} else if privacy == models.Unlisted { |
|||
title = "Unlisted" |
|||
} else if privacy == models.Private { |
|||
title = "Private" |
|||
} |
|||
|
|||
v.V["title"] = title |
|||
v.V["tag"] = tagName |
|||
v.V["privacy"] = privacy |
|||
responses.RenderTemplate(w, r, "_builtin/blog/index.gohtml", v) |
|||
} |
|||
} |
|||
|
|||
// PostFragment at "/<fragment>" for viewing blog entries.
|
|||
func PostFragment(w http.ResponseWriter, r *http.Request) { |
|||
fragment := strings.Trim(r.URL.Path, "/") |
|||
post, err := models.Posts.LoadFragment(fragment) |
|||
if err != nil { |
|||
responses.NotFound(w, r) |
|||
return |
|||
} |
|||
|
|||
// Is it a private post and are we logged in?
|
|||
if post.Privacy != models.Public && post.Privacy != models.Unlisted && !authentication.LoggedIn(r) { |
|||
responses.Forbidden(w, r, "Permission denied to view that post.") |
|||
return |
|||
} |
|||
|
|||
v := responses.NewTemplateVars(w, r) |
|||
v.V["post"] = post |
|||
|
|||
// Render the body.
|
|||
if post.ContentType == models.Markdown { |
|||
v.V["rendered"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) |
|||
} else { |
|||
v.V["rendered"] = template.HTML(post.Body) |
|||
} |
|||
|
|||
responses.RenderTemplate(w, r, "_builtin/blog/view-post.gohtml", v) |
|||
} |
|||
|
|||
// PartialBlogIndex is a template function to embed a blog index view on any page.
|
|||
func PartialBlogIndex(r *http.Request, tag, privacy string) template.HTML { |
|||
html := bytes.NewBuffer([]byte{}) |
|||
v := responses.NewTemplateVars(html, r) |
|||
page, _ := strconv.Atoi(r.FormValue("page")) |
|||
|
|||
var ( |
|||
posts models.PagedPosts |
|||
err error |
|||
) |
|||
|
|||
if tag != "" { |
|||
posts, err = models.Posts.GetPostsByTag(tag, privacy, page, settings.Current.PostsPerPage) |
|||
} else { |
|||
posts, err = models.Posts.GetIndexPosts(privacy, page, settings.Current.PostsPerPage) |
|||
} |
|||
|
|||
if err != nil && err.Error() != "sql: no rows in result set" { |
|||
return template.HTML(fmt.Sprintf("[BlogIndex: %s]", err)) |
|||
} |
|||
|
|||
v.V["posts"] = posts.Posts |
|||
v.V["paging"] = posts |
|||
responses.PartialTemplate(html, r, "_builtin/blog/index.partial.gohtml", v) |
|||
return template.HTML(html.String()) |
|||
} |
|||
|
|||
// EditPost at "/blog/edit"
|
|||
func EditPost(w http.ResponseWriter, r *http.Request) { |
|||
v := responses.NewTemplateVars(w, r) |
|||
v.V["preview"] = "" |
|||
|
|||
// The blog post we're working with.
|
|||
var post = models.Posts.New() |
|||
var isNew = true |
|||
|
|||
// Editing an existing post?
|
|||
if r.FormValue("id") != "" { |
|||
id, _ := strconv.Atoi(r.FormValue("id")) |
|||
if p, err := models.Posts.Load(id); err == nil { |
|||
post = p |
|||
isNew = false |
|||
} |
|||
} |
|||
|
|||
// POST handler: create the admin account.
|
|||
for r.Method == http.MethodPost { |
|||
form, _ := forms.Parse(r) |
|||
|
|||
// Validate form parameters.
|
|||
val := form.Validator() |
|||
val.Require("title") |
|||
val.Require("body") |
|||
post.ParseForm(form) |
|||
if val.HasErrors() { |
|||
v.ValidationError = val.ErrorMap() |
|||
break |
|||
} |
|||
|
|||
// Previewing or submitting the post?
|
|||
switch form.Get("submit") { |
|||
case "preview": |
|||
if post.ContentType == models.Markdown { |
|||
v.V["preview"] = template.HTML(markdown.RenderTrustedMarkdown(post.Body)) |
|||
} else { |
|||
v.V["preview"] = template.HTML(post.Body) |
|||
} |
|||
case "post": |
|||
author, _ := authentication.CurrentUser(r) |
|||
post.AuthorID = author.ID |
|||
|
|||
// When editing, allow to not touch the Last Updated time.
|
|||
if !isNew && form.GetBool("no-update") == true { |
|||
post.UpdatedAt = post.CreatedAt |
|||
} else { |
|||
post.UpdatedAt = time.Now().UTC() |
|||
} |
|||
|
|||
err := post.Save() |
|||
if err != nil { |
|||
v.Error = err |
|||
} else { |
|||
session.Flash(w, r, "Post created!") |
|||
responses.Redirect(w, r, "/"+post.Fragment) |
|||
} |
|||
} |
|||
|
|||
break |
|||
} |
|||
|
|||
v.V["post"] = post |
|||
v.V["isNew"] = isNew |
|||
responses.RenderTemplate(w, r, "_builtin/blog/edit.gohtml", v) |
|||
} |
@ -0,0 +1,162 @@ |
|||
// Package markdown implements a GitHub Flavored Markdown renderer.
|
|||
package markdown |
|||
|
|||
import ( |
|||
"bytes" |
|||
"crypto/md5" |
|||
"errors" |
|||
"fmt" |
|||
"io" |
|||
"os/exec" |
|||
"regexp" |
|||
"strings" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/console" |
|||
"github.com/microcosm-cc/bluemonday" |
|||
"github.com/shurcooL/github_flavored_markdown" |
|||
) |
|||
|
|||
// Regexps for Markdown use cases.
|
|||
var ( |
|||
// TODO: Redis caching
|
|||
// Cache interface{} = nil
|
|||
|
|||
// Match title from the first `# h1` heading.
|
|||
reMarkdownTitle = regexp.MustCompile(`(?m:^#([^#\r\n]+)$)`) |
|||
|
|||
// Match fenced code blocks with languages defined.
|
|||
reFencedCode = regexp.MustCompile("```" + `([a-z]*)[\r\n]([\s\S]*?)[\r\n]\s*` + "```") |
|||
reFencedCodeClass = regexp.MustCompile("^highlight highlight-[a-zA-Z0-9]+$") |
|||
|
|||
// Regexp to match fenced code blocks in rendered Markdown HTML.
|
|||
// Tweak this if you change Markdown engines later.
|
|||
reCodeBlock = regexp.MustCompile(`<div class="highlight highlight-(.+?)"><pre>(.+?)</pre></div>`) |
|||
reDecodeBlock = regexp.MustCompile(`\[?FENCED_CODE_%d_BLOCK?\]`) |
|||
) |
|||
|
|||
// A container for parsed code blocks.
|
|||
type codeBlock struct { |
|||
placeholder int |
|||
language string |
|||
source string |
|||
} |
|||
|
|||
// TitleFromMarkdown tries to find a title from the source of a Markdown file.
|
|||
//
|
|||
// On error, returns "Untitled" along with the error. So if you're lazy and
|
|||
// want a suitable default, you can safely ignore the error.
|
|||
func TitleFromMarkdown(body string) (string, error) { |
|||
m := reMarkdownTitle.FindStringSubmatch(body) |
|||
if len(m) > 0 { |
|||
return m[1], nil |
|||
} |
|||
return "Untitled", errors.New( |
|||
"did not find a single h1 (denoted by # prefix) for Markdown title", |
|||
) |
|||
} |
|||
|
|||
// RenderMarkdown renders markdown to HTML, safely. It uses blackfriday to
|
|||
// render Markdown to HTML and then Bluemonday to sanitize the resulting HTML.
|
|||
func RenderMarkdown(input string) string { |
|||
unsafe := []byte(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 RenderTrustedMarkdown(input string) string { |
|||
// Find and hang on to fenced code blocks.
|
|||
codeBlocks := []codeBlock{} |
|||
matches := reFencedCode.FindAllStringSubmatch(input, -1) |
|||
for i, m := range matches { |
|||
language, source := m[1], m[2] |
|||
if language == "" { |
|||
continue |
|||
} |
|||
codeBlocks = append(codeBlocks, codeBlock{i, language, source}) |
|||
|
|||
input = strings.Replace(input, m[0], fmt.Sprintf( |
|||
"[?FENCED_CODE_%d_BLOCK?]", |
|||
i, |
|||
), 1) |
|||
} |
|||
|
|||
// Render the HTML out.
|
|||
html := string(github_flavored_markdown.Markdown([]byte(input))) |
|||
|
|||
// Substitute fenced codes back in.
|
|||
for _, block := range codeBlocks { |
|||
highlighted, err := Pygmentize(block.language, block.source) |
|||
if err != nil { |
|||
console.Error("Pygmentize error: %s", err) |
|||
} |
|||
html = strings.Replace(html, |
|||
fmt.Sprintf("[?FENCED_CODE_%d_BLOCK?]", block.placeholder), |
|||
highlighted, |
|||
1, |
|||
) |
|||
} |
|||
|
|||
return string(html) |
|||
} |
|||
|
|||
// Pygmentize searches for fenced code blocks in rendered Markdown HTML
|
|||
// and runs Pygments to syntax highlight it.
|
|||
//
|
|||
// On error the original given source is returned back.
|
|||
//
|
|||
// The rendered result is cached in Redis if available, because the CLI
|
|||
// call takes ~0.6s which is slow if you're rendering a lot of code blocks.
|
|||
func Pygmentize(language, source string) (string, error) { |
|||
var result string |
|||
|
|||
// Hash the source for the cache key.
|
|||
h := md5.New() |
|||
io.WriteString(h, language+source) |
|||
// hash := fmt.Sprintf("%x", h.Sum(nil))
|
|||
// cacheKey := "pygmentize:" + hash
|
|||
|
|||
// Do we have it cached?
|
|||
// if Cache != nil {
|
|||
// if cached, err := Cache.Get(cacheKey); err == nil && len(cached) > 0 {
|
|||
// return string(cached), nil
|
|||
// }
|
|||
// }
|
|||
|
|||
// Defer to the `pygmentize` command
|
|||
bin := "pygmentize" |
|||
if _, err := exec.LookPath(bin); err != nil { |
|||
return source, errors.New("pygmentize not installed") |
|||
} |
|||
|
|||
cmd := exec.Command(bin, "-l"+language, "-f"+"html", "-O encoding=utf-8") |
|||
cmd.Stdin = strings.NewReader(source) |
|||
|
|||
var out bytes.Buffer |
|||
cmd.Stdout = &out |
|||
|
|||
var stderr bytes.Buffer |
|||
cmd.Stderr = &stderr |
|||
|
|||
if err := cmd.Run(); err != nil { |
|||
console.Error("Error running pygments: %s", stderr.String()) |
|||
return source, err |
|||
} |
|||
|
|||
result = out.String() |
|||
// if Cache != nil {
|
|||
// err := Cache.Set(cacheKey, []byte(result), 60*60*24) // cool md5's don't change
|
|||
// if err != nil {
|
|||
// console.Error("Couldn't cache Pygmentize output: %s", err)
|
|||
// }
|
|||
// }
|
|||
|
|||
return result, nil |
|||
} |
@ -0,0 +1,14 @@ |
|||
package models |
|||
|
|||
// Constant values for blog posts.
|
|||
const ( |
|||
// ContentType settings.
|
|||
HTML = "html" |
|||
Markdown = "markdown" |
|||
|
|||
// Post privacy settings.
|
|||
Public = "public" |
|||
Private = "private" |
|||
Unlisted = "unlisted" |
|||
Draft = "draft" |
|||
) |
@ -0,0 +1,313 @@ |
|||
package models |
|||
|
|||
import ( |
|||
"errors" |
|||
"fmt" |
|||
"html/template" |
|||
"math" |
|||
"regexp" |
|||
"strings" |
|||
"time" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/console" |
|||
"git.kirsle.net/apps/gophertype/pkg/markdown" |
|||
"github.com/albrow/forms" |
|||
"github.com/jinzhu/gorm" |
|||
) |
|||
|
|||
type postMan struct{} |
|||
|
|||
// Posts is a singleton manager class for Post model access.
|
|||
var Posts = postMan{} |
|||
|
|||
// Post represents a single blog entry.
|
|||
type Post struct { |
|||
gorm.Model |
|||
|
|||
Title string |
|||
Fragment string `gorm:"unique_index"` |
|||
ContentType string `gorm:"default:html"` |
|||
AuthorID uint // foreign key to User.ID
|
|||
Body string |
|||
Privacy string |
|||
|
|||
Sticky bool |
|||
EnableComments bool |
|||
Tags []TaggedPost |
|||
Author User `gorm:"foreign_key:UserID"` |
|||
} |
|||
|
|||
// PagedPosts holds a paginated response of multiple posts.
|
|||
type PagedPosts struct { |
|||
Posts []Post |
|||
Page int |
|||
PerPage int |
|||
Pages int |
|||
Total int |
|||
NextPage int |
|||
PreviousPage int |
|||
} |
|||
|
|||
// TaggedPost associates tags to their posts.
|
|||
type TaggedPost struct { |
|||
ID uint `gorm:"primary_key"` |
|||
Tag string |
|||
PostID uint // foreign key to Post
|
|||
} |
|||
|
|||
// New creates a new Post model.
|
|||
func (m postMan) New() Post { |
|||
return Post{ |
|||
ContentType: Markdown, |
|||
Privacy: Public, |
|||
EnableComments: true, |
|||
} |
|||
} |
|||
|
|||
// Load a post by ID.
|
|||
func (m postMan) Load(id int) (Post, error) { |
|||
var post Post |
|||
r := DB.Preload("Author").Preload("Tags").First(&post, id) |
|||
return post, r.Error |
|||
} |
|||
|
|||
// LoadFragment loads a blog post by its URL fragment.
|
|||
func (m postMan) LoadFragment(fragment string) (Post, error) { |
|||
var post Post |
|||
r := DB.Preload("Author").Preload("Tags").Where("fragment = ?", strings.Trim(fragment, "/")).First(&post) |
|||
return post, r.Error |
|||
} |
|||
|
|||
// GetIndex returns the index page of blog posts.
|
|||
func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, error) { |
|||
var pp = PagedPosts{ |
|||
Page: page, |
|||
PerPage: perPage, |
|||
} |
|||
|
|||
if pp.Page < 1 { |
|||
pp.Page = 1 |
|||
} |
|||
if pp.PerPage <= 0 { |
|||
pp.PerPage = 20 |
|||
} |
|||
|
|||
query := DB.Debug().Preload("Author").Preload("Tags"). |
|||
Where("privacy = ?", privacy). |
|||
Order("sticky desc, created_at desc") |
|||
|
|||
// Count the total number of rows for paging purposes.
|
|||
query.Model(&Post{}).Count(&pp.Total) |
|||
|
|||
// Query the paginated slice of results.
|
|||
r := query. |
|||
Offset((page - 1) * perPage). |
|||
Limit(perPage). |
|||
Find(&pp.Posts) |
|||
|
|||
pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage))) |
|||
if pp.Page < pp.Pages { |
|||
pp.NextPage = pp.Page + 1 |
|||
} |
|||
if pp.Page > 1 { |
|||
pp.PreviousPage = pp.Page - 1 |
|||
} |
|||
|
|||
return pp, r.Error |
|||
} |
|||
|
|||
// GetPostsByTag gets posts by a certain tag.
|
|||
func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPosts, error) { |
|||
var pp = PagedPosts{ |
|||
Page: page, |
|||
PerPage: perPage, |
|||
} |
|||
|
|||
if pp.Page < 1 { |
|||
pp.Page = 1 |
|||
} |
|||
if pp.PerPage <= 0 { |
|||
pp.PerPage = 20 |
|||
} |
|||
|
|||
// Get the distinct post IDs for this tag.
|
|||
var tags []TaggedPost |
|||
var postIDs []uint |
|||
r := DB.Where("tag = ?", tag).Find(&tags) |
|||
for _, taggedPost := range tags { |
|||
postIDs = append(postIDs, taggedPost.PostID) |
|||
} |
|||
|
|||
if len(postIDs) == 0 { |
|||
return pp, errors.New("no posts found") |
|||
} |
|||
|
|||
// Query this set of posts.
|
|||
query := DB.Debug().Preload("Author").Preload("Tags"). |
|||
Where("id IN (?) AND privacy = ?", postIDs, privacy). |
|||
Order("sticky desc, created_at desc") |
|||
|
|||
// Count the total number of rows for paging purposes.
|
|||
query.Model(&Post{}).Count(&pp.Total) |
|||
|
|||
// Query the paginated slice of results.
|
|||
r = query. |
|||
Offset((page - 1) * perPage). |
|||
Limit(perPage). |
|||
Find(&pp.Posts) |
|||
|
|||
pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage))) |
|||
if pp.Page < pp.Pages { |
|||
pp.NextPage = pp.Page + 1 |
|||
} |
|||
if pp.Page > 1 { |
|||
pp.PreviousPage = pp.Page - 1 |
|||
} |
|||
|
|||
return pp, r.Error |
|||
} |
|||
|
|||
// PreviewHTML returns the post's body as rendered HTML code, but only above
|
|||
// the <snip> tag for index views.
|
|||
func (p Post) PreviewHTML() template.HTML { |
|||
var ( |
|||
parts = strings.Split(p.Body, "<snip>") |
|||
hasMore = len(parts) > 1 |
|||
body = strings.TrimSpace(parts[0]) |
|||
) |
|||
|
|||
if p.ContentType == Markdown { |
|||
if hasMore { |
|||
body += fmt.Sprintf("\n\n[Read more...](/%s)", p.Fragment) |
|||
} |
|||
return template.HTML(markdown.RenderTrustedMarkdown(body)) |
|||
} |
|||
|
|||
body += fmt.Sprintf(`<p><a href="/%s">Read more...</a></p>`, p.Fragment) |
|||
return template.HTML(body) |
|||
} |
|||
|
|||
// HTML returns the post's body as rendered HTML code.
|
|||
func (p Post) HTML() template.HTML { |
|||
body := strings.ReplaceAll(p.Body, "<snip>", "") |
|||
if p.ContentType == Markdown { |
|||
return template.HTML(markdown.RenderTrustedMarkdown(body)) |
|||
} |
|||
return template.HTML(body) |
|||
} |
|||
|
|||
// Save a post.
|
|||
// This method also makes sure a unique Fragment is set and links the Tags correctly.
|
|||
func (p *Post) Save() error { |
|||
// Generate the default fragment from the post title.
|
|||
if p.Fragment == "" { |
|||
fragment := strings.ToLower(p.Title) |
|||
fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-") |
|||
fragment = strings.ReplaceAll(fragment, "--", "-") |
|||
console.Error("frag: %s", fragment) |
|||
p.Fragment = strings.Trim(fragment, "-") |
|||
|
|||
// If still no fragment, make one up from the current time.
|
|||
if p.Fragment == "" { |
|||
p.Fragment = time.Now().Format("2006-01-02-150405") |
|||
} |
|||
} |
|||
|
|||
// Ensure the fragment is unique!
|
|||
{ |
|||
if exist, err := Posts.LoadFragment(p.Fragment); err != nil && exist.ID != p.ID { |
|||
console.Debug("Post.Save: fragment %s is not unique, trying to resolve", p.Fragment) |
|||
var resolved bool |
|||
for i := 2; i <= 100; i++ { |
|||
fragment := fmt.Sprintf("%s-%d", p.Fragment, i) |
|||
console.Debug("Post.Save: try fragment '%s'", fragment) |
|||
_, err = Posts.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) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Empty tags list.
|
|||
if len(p.Tags) == 1 && p.Tags[0].Tag == "" { |
|||
p.Tags = []TaggedPost{} |
|||
} |
|||
|
|||
// TODO: tag relationships. For now just delete and re-add them all.
|
|||
if p.ID != 0 { |
|||
DB.Where("post_id = ?", p.ID).Delete(TaggedPost{}) |
|||
} |
|||
|
|||
// Dedupe tags.
|
|||
p.fixTags() |
|||
|
|||
// Save the post.
|
|||
if DB.NewRecord(p) { |
|||
return DB.Create(&p).Error |
|||
} |
|||
|
|||
return DB.Save(&p).Error |
|||
} |
|||
|
|||
// ParseForm populates a Post from an HTTP form.
|
|||
func (p *Post) ParseForm(form *forms.Data) { |
|||
p.Title = form.Get("title") |
|||
p.Fragment = form.Get("fragment") |
|||
p.ContentType = form.Get("content-type") |
|||
p.Body = form.Get("body") |
|||
p.Privacy = form.Get("privacy") |
|||
p.Sticky = form.GetBool("sticky") |
|||
p.EnableComments = form.GetBool("enable-comments") |
|||
|
|||
// Parse the tags array. This replaces the post.Tags with an empty TaggedPost
|
|||
// list containing only the string Tag values. The IDs and DB side will be
|
|||
// patched up when the post gets saved.
|
|||
p.Tags = []TaggedPost{} |
|||
tags := strings.Split(form.Get("tags"), ",") |
|||
for _, tag := range tags { |
|||
tag = strings.TrimSpace(tag) |
|||
if len(tag) == 0 { |
|||
continue |
|||
} |
|||
|
|||
p.Tags = append(p.Tags, TaggedPost{ |
|||
Tag: tag, |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TagsString turns the post tags into a comma separated string.
|
|||
func (p Post) TagsString() string { |
|||
console.Error("TagsString: %+v", p.Tags) |
|||
var tags = make([]string, len(p.Tags)) |
|||
for i, tag := range p.Tags { |
|||
tags[i] = tag.Tag |
|||
} |
|||
return strings.Join(tags, ", ") |
|||
} |
|||
|
|||
// fixTags is a pre-Save function to fix up the Tags relationships.
|
|||
// It checks that each tag has an ID, and if it doesn't have an ID yet, removes
|
|||
// it if a duplicate tag does exist that has an ID.
|
|||
func (p *Post) fixTags() { |
|||
// De-duplicate tag values.
|
|||
var dedupe = map[string]interface{}{} |
|||
var finalTags []TaggedPost |
|||
for _, tag := range p.Tags { |
|||
if _, ok := dedupe[tag.Tag]; !ok { |
|||
finalTags = append(finalTags, tag) |
|||
dedupe[tag.Tag] = nil |
|||
} |
|||
} |
|||
|
|||
p.Tags = finalTags |
|||
} |
@ -0,0 +1,133 @@ |
|||
{{ define "title" }}Update Blog{{ end }} |
|||
{{ define "content" }} |
|||
<h1>Update Blog</h1> |
|||
|
|||
{{ if .V.preview }} |
|||
<div class="card mb-4"> |
|||
<div class="card-header"> |
|||
Preview |
|||
</div> |
|||
<div class="card-body"> |
|||
{{ .V.preview }} |
|||
</div> |
|||
</div> |
|||
{{ end }} |
|||
|
|||
{{ $Post := .V.post }} |
|||
|
|||
<form method="POST" action="/blog/edit"> |
|||
{{ CSRF }} |
|||
<input type="hidden" name="id" value="{{ $Post.ID }}"> |
|||
<div class="card mb-4"> |
|||
<div class="card-body"> |
|||
<div class="form-group"> |
|||
<label for="title">Title</label> |
|||
<input type="text" class="form-control" |
|||
name="title" id="title" |
|||
value="{{ $Post.Title }}" |
|||
placeholder="Subject"> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label for="fragment">URL Fragment</label> |
|||
<input type="text" class="form-control" |
|||
name="fragment" id="fragment" |
|||
aria-describedby="fragment-help" |
|||
value="{{ $Post.Fragment }}" |
|||
placeholder="url-fragment-for-blog-entry"> |
|||
<small id="fragment-help" class="form-text text-muted"> |
|||
You can leave this blank if writing a new post; it will automatically |
|||
get a unique fragment based on the post title. |
|||
</small> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<div class="float-right"> |
|||
<label> |
|||
<input type="radio" name="content-type" value="markdown"{{ if ne $Post.ContentType "html" }} checked{{ end }}> |
|||
Markdown |
|||
</label> |
|||
|
|||
<label> |
|||
<input type="radio" name="content-type" value="html"{{ if eq $Post.ContentType "html" }} checked{{ end }}> |
|||
HTML |
|||
</label> |
|||
</div> |
|||
<label for="body">Body</label> |
|||
<textarea class="form-control" cols="40" rows="12" name="body">{{ $Post.Body }}</textarea> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label for="tags">Tags</label> |
|||
<input type="text" class="form-control" |
|||
name="tags" id="tags" |
|||
value="{{ $Post.TagsString }}" |
|||
placeholder="comma, separated, list"> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label for="privacy">Privacy</label> |
|||
<select class="form-control" |
|||
name="privacy" id="privacy"> |
|||
<option value="public"{{ if eq $Post.Privacy "public" }} selected{{ end }}> |
|||
Public: everyone can see this post</option> |
|||
<option value="private"{{ if eq $Post.Privacy "private" }} selected{{ end }}> |
|||
Private: only logged-in users can see this post</option> |
|||
<option value="unlisted"{{ if eq $Post.Privacy "unlisted" }} selected{{ end }}> |
|||
Unlisted: only logged-in users and those with the direct link can see this post</option> |
|||
<option value="draft"{{ if eq $Post.Privacy "draft" }} selected{{ end }}> |
|||
Draft: only you can see this post</option> |
|||
</select> |
|||
</div> |
|||
|
|||
<div class="form-group"> |
|||
<label>Options</label> |
|||
</div> |
|||
|
|||
<div class="form-check"> |
|||
<input type="checkbox" class="form-input-check" |
|||
name="sticky" id="sticky" |
|||
value="true" |
|||
{{ if $Post.Sticky }} checked{{ end }}> |
|||
<label class="check-form-label" for="sticky"> |
|||
Make this post sticky (always on top) |
|||
</label> |
|||
</div> |
|||
|
|||
<div class="form-check"> |
|||
<input type="checkbox" class="form-input-check" |
|||
name="enable-comments" id="enable-comments" |
|||
value="true" |
|||
{{ if $Post.EnableComments }} checked{{ end }}> |
|||
<label class="check-form-label" for="enable-comments"> |
|||
Enable comments on this post |
|||
</label> |
|||
</div> |
|||
|
|||
{{ if not .V.isNew }} |
|||
<div class="form-check"> |
|||
<input type="checkbox" class="form-input-check" |
|||
name="no-update" id="no-update" |
|||
value="true" |
|||
{{ if eq (FormValue "no-update") "true" }} checked{{ end }}> |
|||
<label class="check-form-label" for="no-update"> |
|||
<strong>Editing:</strong> do not update the "modified time" of this post. |
|||
</label> |
|||
</div> |
|||
{{ end }} |
|||
|
|||
<div class="form-group"> |
|||
<button type="submit" class="btn btn-success" |
|||
name="submit" value="preview"> |
|||
Preview |
|||
</button> |
|||
|
|||
<button type="submit" class="btn btn-primary" |
|||
name="submit" value="post"> |
|||
Publish |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
{{ end }} |
@ -0,0 +1,8 @@ |
|||
{{ define "title" }}{{ or .V.title "Blog" }}{{ end }} |
|||
{{ define "content" }} |
|||
|
|||
<h1>{{ or .V.title "Blog" }}</h1> |
|||
|
|||
{{ BlogIndex .Request .V.tag .V.privacy }} |
|||
|
|||
{{ end }} |
@ -0,0 +1,77 @@ |
|||
{{ range $i, $Post := .V.posts }} |
|||
{{ if gt $i 0 }}<hr class="mb-4">{{ end }} |
|||
|
|||
<div class="card mb-4"> |
|||
<div class="card-header"> |
|||
<h1> |
|||
<a href="/{{ $Post.Fragment }}" class="blog-title">{{ $Post.Title }}</a> |
|||
</h1> |
|||
</div> |
|||
<div class="card-body"> |
|||
<small class="text-muted blog-meta"><em> |
|||
{{ if $Post.Sticky }}<span class="blog-sticky">[sticky]</span>{{ end }} |
|||
{{ if ne $Post.Privacy "public" }} |
|||
<span class="blog-{{ $Post.Privacy }}">[{{ $Post.Privacy }}]</span> |
|||
{{ end }} |
|||
|
|||
<span title="{{ $Post.CreatedAt.Format "Jan 2 2006 15:04:05 MST" }}"> |
|||
{{ $Post.CreatedAt.Format "January 2, 2006" }} |
|||
</span> |
|||
|
|||
{{ if ($Post.UpdatedAt.After $Post.CreatedAt) }} |
|||
<span title="{{ $Post.UpdatedAt.Format "Jan 2 2006 15:04:05 MST" }}"> |
|||
(updated {{ $Post.UpdatedAt.Format "January 2, 2006" }}) |
|||
</span> |
|||
{{ end }} |
|||
|
|||
{{ if $Post.Author.Name }} |
|||
by {{ $Post.Author.Name }} |
|||
{{ end }} |
|||
</em></small> |
|||
<br><br> |
|||
|
|||
{{ $Post.PreviewHTML }} |
|||
|
|||
<div class="mt-4"> |
|||
<small class="text-muted"><em> |
|||
Tags: |
|||
|
|||
{{ range $tag := $Post.Tags }} |
|||
<a href="/tagged/{{ $tag.Tag }}" class="ml-2">#{{ $tag.Tag }}</a> |
|||
{{ end }} |
|||
</em></small> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{{ if $.CurrentUser.IsAdmin }} |
|||
<div class="alert alert-secondary"> |
|||
<small> |
|||
<strong>Admin:</strong> |
|||
[ |
|||
<a href="/blog/edit?id={{ $Post.ID }}">edit</a> | |
|||
<a href="/blog/delete?id={{ $Post.ID }}">delete</a> |
|||
] |
|||
</small> |
|||
</div> |
|||
{{ end }} |
|||
{{ end }} |
|||
|
|||
{{ if .V.paging }} |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<span class="badge badge-secondary" title="{{ .V.paging.Total }} total posts"> |
|||
Page {{ .V.paging.Page }} of {{ .V.paging.Pages }} |
|||
</span> |
|||
</div> |
|||
|
|||
<div class="col text-right"> |
|||
{{ if .V.paging.PreviousPage }} |
|||
<a href="?page={{ .V.paging.PreviousPage }}" class="btn btn-sm btn-light">Newer posts</a> |
|||
{{ end }} |
|||
{{ if .V.paging.NextPage }} |
|||
<a href="?page={{ .V.paging.NextPage }}" class="btn btn-sm btn-primary">Older posts</a> |
|||
{{ end }} |
|||
</div> |
|||
</div> |
|||
{{ end }} |
@ -0,0 +1,56 @@ |
|||
{{ define "title" }}{{ .V.post.Title }}{{ end }} |
|||
{{ define "content" }} |
|||
{{ $Post := .V.post }} |
|||
|
|||
<div class="card mb-4"> |
|||
<div class="card-header"> |
|||
<h1 class="blog-title">{{ $Post.Title }}</h1> |
|||
</div> |
|||
<div class="card-body"> |
|||
<small class="text-muted blog-meta"><em> |
|||
{{ if $Post.Sticky }}<span class="blog-sticky">[sticky]</span>{{ end }} |
|||
{{ if ne $Post.Privacy "public" }} |
|||
<span class="blog-{{ $Post.Privacy }}">[{{ $Post.Privacy }}]</span> |
|||
{{ end }} |
|||
|
|||
<span title="{{ $Post.CreatedAt.Format "Jan 2 2006 15:04:05 MST" }}"> |
|||
{{ $Post.CreatedAt.Format "January 2, 2006" }} |
|||
</span> |
|||
|
|||
{{ if ($Post.UpdatedAt.After $Post.CreatedAt) }} |
|||
<span title="{{ $Post.UpdatedAt.Format "Jan 2 2006 15:04:05 MST" }}"> |
|||
(updated {{ $Post.UpdatedAt.Format "January 2, 2006" }}) |
|||
</span> |
|||
{{ end }} |
|||
|
|||
{{ if $Post.Author.Name }} |
|||
by {{ $Post.Author.Name }} |
|||
{{ end }} |
|||
</em></small> |
|||
<br><br> |
|||
|
|||
{{ $Post.HTML }} |
|||
|
|||
<div class="mt-4"> |
|||
<small class="text-muted"><em> |
|||
Tags: |
|||
|
|||
{{ range $tag := $Post.Tags }} |
|||
<a href="/tagged/{{ $tag.Tag }}" class="ml-2">#{{ $tag.Tag }}</a> |
|||
{{ end }} |
|||
</em></small> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{{ if .CurrentUser.IsAdmin }} |
|||
<div class="alert alert-secondary"> |
|||
<strong>Admin:</strong> |
|||
[ |
|||
<a href="/blog/edit?id={{ $Post.ID }}">edit</a> | |
|||
<a href="/blog/delete?id={{ $Post.ID }}">delete</a> |
|||
] |
|||
</div> |
|||
{{ end }} |
|||
|
|||
{{ end }} |
@ -0,0 +1,6 @@ |
|||
{{ define "title" }}{{ or .V.title "Untitled Markdown Document" }}{{ end }} |
|||
{{ define "content" }} |
|||
|
|||
{{ .V.markdown }} |
|||
|
|||
{{ end }} |
Loading…
Reference in new issue