Blog Archive, RSS Feeds, and Model Cleanup

* Legacy-importer tool updates the DB primary key serial after migrating
  the posts, to be max(posts.id)+1 -- especially important for
  PostgreSQL and MySQL (SQLite3 correctly picked the next ID by
  default?)
* Add blog archive page and RSS, Atom and JSON feeds for the blog.
  URLs are /blog.rss, /blog.atom and /blog.json
This commit is contained in:
Noah 2020-02-17 18:10:35 -08:00
parent 383b5d7591
commit 5b6712ea97
17 changed files with 378 additions and 37 deletions

View File

@ -80,7 +80,9 @@ func main() {
fmt.Println("Hello world")
app := gophertype.NewSite(optRoot)
app.UseDB(dbDriver, dbPath)
if err := app.UseDB(dbDriver, dbPath); err != nil {
panic(err)
}
app.SetupRouter()
if err := app.ListenAndServe(optBind); err != nil {

View File

@ -107,6 +107,7 @@ func initJsonDB() {
func doMigrate() {
migratePosts()
migrateComments()
resetSerial("posts")
}
// migratePosts migrates blog posts over.
@ -137,13 +138,13 @@ func migratePosts() {
Title: post.Title,
Fragment: post.Fragment,
ContentType: post.ContentType,
AuthorID: uint(post.AuthorID),
AuthorID: post.AuthorID,
Body: post.Body,
Privacy: post.Privacy,
Sticky: post.Sticky,
EnableComments: post.EnableComments,
}
p.ID = uint(post.ID)
p.ID = post.ID
p.CreatedAt = post.Created
p.UpdatedAt = post.Updated
@ -158,6 +159,11 @@ func migratePosts() {
if err != nil {
console.Error("Error saving post %d: %s", p.ID, err)
}
// Set the correct updated_at time.
models.DB.Table("posts").Where("id = ?", p.ID).Updates(map[string]interface{}{
"updated_at": post.Updated,
})
}
console.Warn("FINISH: Migrating blog posts")
@ -167,6 +173,14 @@ func migratePosts() {
func migrateComments() {
console.Warn("BEGIN: Migrating comments")
// Only migrate comments one time.
var count int
models.DB.Model(&models.Comment{}).Count(&count)
if count > 0 {
console.Info("Comments already seem imported (count > 0, count == %d); skipping.", count)
return
}
// Find all the comment threads.
files, err := JsonDB.List("comments/threads")
if err != nil {
@ -183,7 +197,7 @@ func migrateComments() {
// Migrate to the new model.
com := models.Comment{
Thread: thread.ID,
UserID: uint(comment.UserID),
UserID: comment.UserID,
Name: comment.Name,
Email: comment.Email,
Avatar: comment.Avatar,
@ -207,3 +221,31 @@ func migrateComments() {
console.Warn("FINISH: Migrating comments")
}
// resetSerial fixes the auto-incrementing ID for the next row added.
func resetSerial(table string) {
// Get the max ID from the table.
result := struct {
Max int
}{}
models.DB.Table(table).Select("max(id) AS max").Scan(&result)
// Run the query based on dialect.
var query string
switch dbDriver {
case "postgres":
query = fmt.Sprintf("ALTER SEQUENCE %s RESTART WITH %d", table+"_id_seq", result.Max+1)
case "sqlite3":
query = fmt.Sprintf("UPDATE SQLITE_SEQUENCE SET SEQ=%d WHERE NAME=%s", result.Max+1, table)
case "mysql":
query = fmt.Sprintf("ALTER TABLE %s AUTO_INCREMENT = %d", table, result.Max+1)
}
if len(query) > 0 {
var noop []string
r := models.DB.Raw(query).Scan(&noop)
if r.Error != nil {
console.Error("Error with query `%s`: %s", query, r.Error)
}
}
}

1
go.mod
View File

@ -5,6 +5,7 @@ go 1.13
require (
github.com/albrow/forms v0.3.3
github.com/edwvee/exiffix v0.0.0-20180602190213-b57537c92a6b
github.com/gorilla/feeds v1.1.1
github.com/gorilla/mux v1.7.3
github.com/gorilla/sessions v1.2.0
github.com/jinzhu/gorm v1.9.11

1
go.sum
View File

@ -45,6 +45,7 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=

View File

@ -14,9 +14,17 @@ import (
func CurrentUser(r *http.Request) (models.User, error) {
sess := session.Get(r)
if loggedIn, ok := sess.Values["logged-in"].(bool); ok && loggedIn {
id := sess.Values["user-id"].(int)
user, err := models.GetUserByID(id)
return user, err
if id, ok := sess.Values["user-id"].(int); ok && id > 0 {
user, err := models.Users.GetUserByID(id)
if err != nil {
console.Error("CurrentUser: user '%d' was not found in DB! Logging out the session", id)
delete(sess.Values, "user-id")
delete(sess.Values, "logged-in")
return user, err
}
return user, err
}
return models.User{}, errors.New("not logged in")
}
return models.User{}, errors.New("not logged in")
}

53
pkg/common/url_convert.go Normal file
View File

@ -0,0 +1,53 @@
package common
import (
"fmt"
"regexp"
"strings"
)
var (
// Regexp to locate relative URLs in HTML code.
reRelativeLink = regexp.MustCompile(` (src|href|poster)=(['"])/([^'"]+)['"]`)
// Regexp to detect common URL schemes.
reURLScheme = regexp.MustCompile(`^https?://`)
)
// ReplaceRelativeLinksToAbsolute searches an HTML snippet for relative links
// and replaces them with absolute ones using the given base URL.
func ReplaceRelativeLinksToAbsolute(baseURL string, html string) string {
if baseURL == "" {
return html
}
matches := reRelativeLink.FindAllStringSubmatch(html, -1)
for _, match := range matches {
var (
attr = match[1]
quote = match[2]
uri = match[3]
absURI = strings.TrimSuffix(baseURL, "/") + "/" + uri
replace = fmt.Sprintf(" %s%s%s%s",
attr, quote, absURI, quote,
)
)
html = strings.Replace(html, match[0], replace, 1)
}
return html
}
// ToAbsoluteURL converts a URL to an absolute one. If the URL is already
// absolute, it is returned as-is.
func ToAbsoluteURL(uri string, baseURL string) string {
match := reURLScheme.FindStringSubmatch(uri)
if len(match) > 0 {
return uri
}
if strings.HasPrefix(uri, "/") {
return strings.TrimSuffix(baseURL, "/") + uri
}
return strings.TrimSuffix(baseURL, "/") + "/" + uri
}

View File

@ -35,7 +35,7 @@ func init() {
}
// Check authentication.
user, err := models.AuthenticateUser(form.Get("email"), form.Get("password"))
user, err := models.Users.AuthenticateUser(form.Get("email"), form.Get("password"))
if err != nil {
v.Error = err
break

View File

@ -34,7 +34,7 @@ func InitialSetup(w http.ResponseWriter, r *http.Request) {
v.SetupNeeded = false // supress the banner on this page.
// See if we already have an admin account.
if _, err := models.FirstAdmin(); err == nil {
if _, err := models.Users.FirstAdmin(); err == nil {
v.Message = "This site is already initialized."
responses.Forbidden(w, r, "This site has already been initialized.")
return
@ -85,7 +85,7 @@ func InitialSetup(w http.ResponseWriter, r *http.Request) {
}
admin.SetPassword(password)
if err := models.CreateUser(admin); err != nil {
if err := models.Users.CreateUser(admin); err != nil {
v.Error = err
} else {
// Admin created! Make the default config.

View File

@ -36,6 +36,11 @@ func init() {
Methods: []string{"GET"},
Handler: BlogIndex(models.Public, true),
})
glue.Register(glue.Endpoint{
Path: "/archive",
Methods: []string{"GET"},
Handler: BlogArchive,
})
glue.Register(glue.Endpoint{
Path: "/blog/drafts",
Middleware: []mux.MiddlewareFunc{
@ -175,6 +180,22 @@ func TagIndex(w http.ResponseWriter, r *http.Request) {
responses.RenderTemplate(w, r, "_builtin/blog/tags.gohtml", v)
}
// BlogArchive shows the archive page of ALL blog posts.
func BlogArchive(w http.ResponseWriter, r *http.Request) {
v := responses.NewTemplateVars(w, r)
// Show private and unlisted posts?
showPrivate := authentication.LoggedIn(r)
archive, err := models.Posts.GetArchive(showPrivate)
if err != nil {
responses.Error(w, r, http.StatusInternalServerError, err.Error())
return
}
v.V["archive"] = archive
responses.RenderTemplate(w, r, "_builtin/blog/archive.gohtml", v)
}
// EditPost at "/blog/edit"
func EditPost(w http.ResponseWriter, r *http.Request) {
v := responses.NewTemplateVars(w, r)

105
pkg/controllers/rss.go Normal file
View File

@ -0,0 +1,105 @@
package controllers
import (
"fmt"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"git.kirsle.net/apps/gophertype/pkg/common"
"git.kirsle.net/apps/gophertype/pkg/glue"
"git.kirsle.net/apps/gophertype/pkg/models"
"git.kirsle.net/apps/gophertype/pkg/responses"
"git.kirsle.net/apps/gophertype/pkg/settings"
"github.com/gorilla/feeds"
)
func init() {
glue.Register(glue.Endpoint{
Path: "/blog.rss",
Methods: []string{"GET"},
Handler: RSSFeed,
})
glue.Register(glue.Endpoint{
Path: "/blog.atom",
Methods: []string{"GET"},
Handler: RSSFeed,
})
glue.Register(glue.Endpoint{
Path: "/blog.json",
Methods: []string{"GET"},
Handler: RSSFeed,
})
}
// RSSFeed returns the RSS (or Atom) feed for the blog.
func RSSFeed(w http.ResponseWriter, r *http.Request) {
// Get the first (admin) user for the feed.
admin, err := models.Users.FirstAdmin()
if err != nil {
responses.Error(w, r, http.StatusBadRequest, "Blog isn't ready yet.")
return
}
feed := &feeds.Feed{
Title: settings.Current.Title,
Link: &feeds.Link{Href: settings.Current.BaseURL},
Description: settings.Current.Description,
Author: &feeds.Author{
Name: admin.Name,
Email: admin.Email,
},
Created: time.Now(),
Items: []*feeds.Item{},
}
pp, err := models.Posts.GetIndexPosts(models.Public, 1, settings.Current.PostsPerPage)
if err != nil {
responses.Error(w, r, http.StatusInternalServerError, err.Error())
return
}
for _, post := range pp.Posts {
// If blog is NSFW, allow feed readers to skip the age gate.
var urlSuffix string
if settings.Current.NSFW {
urlSuffix = "?over18=1"
}
item := &feeds.Item{
Id: fmt.Sprintf("%d", post.ID),
Title: post.Title,
Link: &feeds.Link{Href: settings.Current.BaseURL + "/" + post.Fragment + urlSuffix},
// Description is the HTML with relative links made absolute.
Description: common.ReplaceRelativeLinksToAbsolute(settings.Current.BaseURL, string(post.HTML())),
Created: post.CreatedAt,
}
// Attach the thumbnail?
if post.Thumbnail != "" {
item.Enclosure = &feeds.Enclosure{
Url: common.ToAbsoluteURL(post.Thumbnail, settings.Current.BaseURL),
Type: mime.TypeByExtension(filepath.Ext(post.Thumbnail)),
}
}
feed.Items = append(feed.Items, item)
}
// What format to encode it in?
if strings.Contains(r.URL.Path, ".atom") {
atom, _ := feed.ToAtom()
w.Header().Set("Content-Type", "application/atom+xml; encoding=utf-8")
w.Write([]byte(atom))
} else if strings.Contains(r.URL.Path, ".json") {
jsonData, _ := feed.ToJSON()
w.Header().Set("Content-Type", "application/json; encoding=utf-8")
w.Write([]byte(jsonData))
} else {
rss, _ := feed.ToRss()
w.Header().Set("Content-Type", "application/rss+xml; encoding=utf-8")
w.Write([]byte(rss))
}
}

View File

@ -129,7 +129,7 @@ func NotifyComment(subject string, originURL string, c models.Comment) {
}
// Email the site admins.
if adminEmails, err := models.ListAdminEmails(); err == nil {
if adminEmails, err := models.Users.ListAdminEmails(); err == nil {
email.To = strings.Join(adminEmails, ", ")
email.Admin = true
console.Info("Mail site admin '%s' about comment notification on '%s'", email.To, c.Thread)

View File

@ -15,7 +15,6 @@ import (
"git.kirsle.net/apps/gophertype/pkg/console"
"git.kirsle.net/apps/gophertype/pkg/markdown"
"github.com/albrow/forms"
"github.com/jinzhu/gorm"
uuid "github.com/satori/go.uuid"
)
@ -29,11 +28,11 @@ var Comments = commentMan{}
// Comment model.
type Comment struct {
gorm.Model
BaseModel
Thread string `gorm:"index"` // name of comment thread
UserID uint // foreign key to User.ID
PostID uint // if a comment on a blog post
UserID int // foreign key to User.ID
PostID int // if a comment on a blog post
OriginURL string // original URL of comment page
Name string
Email string
@ -201,7 +200,7 @@ func (c *Comment) Save() error {
// Parse the thread name if it looks like a post ID.
if m := rePostThread.FindStringSubmatch(c.Thread); len(m) > 0 {
if postID, err := strconv.Atoi(m[1]); err == nil {
c.PostID = uint(postID)
c.PostID = postID
}
}

View File

@ -1,15 +1,26 @@
package models
import "github.com/jinzhu/gorm"
import (
"time"
"github.com/jinzhu/gorm"
)
// DB is the database handle for all the models.
var DB *gorm.DB
// BaseModel is the base column set for all models.
type BaseModel struct {
ID int `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
}
// UseDB registers a database driver.
func UseDB(db *gorm.DB) {
DB = db
DB.AutoMigrate(&User{})
DB.AutoMigrate(&Post{})
DB.Debug().AutoMigrate(&Post{})
DB.AutoMigrate(&TaggedPost{})
DB.AutoMigrate(&Comment{})
}

View File

@ -13,7 +13,6 @@ import (
"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{}
@ -23,12 +22,12 @@ var Posts = postMan{}
// Post represents a single blog entry.
type Post struct {
gorm.Model
BaseModel
Title string
Fragment string `gorm:"unique_index"`
ContentType string `gorm:"default:html"`
AuthorID uint // foreign key to User.ID
ContentType string `gorm:"default:'html'"`
AuthorID int // foreign key to User.ID
Thumbnail string // image thumbnail for the post
Body string
Privacy string
@ -53,6 +52,13 @@ type PagedPosts struct {
PreviousPage int
}
// PostArchive holds the posts for a single year/month for the archive page.
type PostArchive struct {
Label string
Date time.Time
Posts []Post
}
// Regexp for matching a thumbnail image for a blog post.
var ThumbnailImageRegexp = regexp.MustCompile(`['"(]([a-zA-Z0-9-_:/?.=&]+\.(?:jpe?g|png|gif))['")]`)
@ -93,7 +99,7 @@ func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, e
pp.PerPage = 20
}
query := DB.Debug().Preload("Author").Preload("Tags").
query := DB.Preload("Author").Preload("Tags").
Where("privacy = ?", privacy).
Order("sticky desc, created_at desc")
@ -148,7 +154,7 @@ func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPos
}
// Query this set of posts.
query := DB.Debug().Preload("Author").Preload("Tags").
query := DB.Preload("Author").Preload("Tags").
Where("id IN (?) AND privacy = ?", postIDs, privacy).
Order("sticky desc, created_at desc")
@ -176,10 +182,60 @@ func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPos
return pp, r.Error
}
// GetArchive queries the archive view of the blog.
// Set private=true to return private posts, false returns public only.
func (m postMan) GetArchive(private bool) ([]*PostArchive, error) {
var result = []*PostArchive{}
query := DB.Table("posts").
Select("title, fragment, thumbnail, created_at, privacy")
if !private {
query = query.Where("privacy=?", Public)
}
rows, err := query.
Order("created_at desc").
Rows()
if err != nil {
return result, err
}
// Group the posts by their month/year.
var months []string
var byMonth = map[string]*PostArchive{}
for rows.Next() {
var row Post
if err := rows.Scan(&row.Title, &row.Fragment, &row.Thumbnail, &row.CreatedAt, &row.Privacy); err != nil {
return result, err
}
label := row.CreatedAt.Format("2006-01")
if _, ok := byMonth[label]; !ok {
months = append(months, label)
byMonth[label] = &PostArchive{
Label: label,
Date: time.Date(
row.CreatedAt.Year(), row.CreatedAt.Month(), 1,
0, 0, 0, 0, time.UTC,
),
Posts: []Post{},
}
}
byMonth[label].Posts = append(byMonth[label].Posts, row)
}
for _, month := range months {
result = append(result, byMonth[month])
}
return result, nil
}
// CountComments gets comment counts for one or more posts.
// Returns a map[uint]int mapping post ID to comment count.
func (m postMan) CountComments(posts ...Post) (map[uint]int, error) {
var result = map[uint]int{}
func (m postMan) CountComments(posts ...Post) (map[int]int, error) {
var result = map[int]int{}
// Create the comment thread IDs.
var threadIDs = make([]string, len(posts))
@ -208,7 +264,7 @@ func (m postMan) CountComments(posts ...Post) (map[uint]int, error) {
if err != nil {
console.Warn("CountComments: strconv.Atoi(%s): %s", thread, err)
}
result[uint(postID)] = count
result[postID] = count
}
}

View File

@ -6,7 +6,7 @@ import (
// TaggedPost associates tags to their posts.
type TaggedPost struct {
ID uint `gorm:"primary_key"`
ID int `gorm:"primary_key"`
Tag string
PostID uint // foreign key to Post
}

View File

@ -7,13 +7,18 @@ import (
"git.kirsle.net/apps/gophertype/pkg/console"
"git.kirsle.net/apps/gophertype/pkg/constants"
"github.com/jinzhu/gorm"
"golang.org/x/crypto/bcrypt"
)
type userMan struct{}
// Users is a singleton helper to deal with user models.
var Users = userMan{}
// User account for the site.
type User struct {
gorm.Model
BaseModel
Email string `gorm:"unique_index"`
Name string
HashedPassword string `json:"-"`
@ -36,8 +41,8 @@ func (u *User) Validate() error {
}
// AuthenticateUser checks a login for an email and password.
func AuthenticateUser(email string, password string) (User, error) {
user, err := GetUserByEmail(email)
func (m userMan) AuthenticateUser(email string, password string) (User, error) {
user, err := m.GetUserByEmail(email)
if err != nil {
console.Error("AuthenticateUser: email %s not found: %s", email, err)
return User{}, errors.New("incorrect email or password")
@ -51,21 +56,21 @@ func AuthenticateUser(email string, password string) (User, error) {
}
// GetUserByID looks up a user by their ID.
func GetUserByID(id int) (User, error) {
func (m userMan) GetUserByID(id int) (User, error) {
var user User
r := DB.First(&user, id)
return user, r.Error
}
// GetUserByEmail looks up a user by their email address.
func GetUserByEmail(email string) (User, error) {
func (m userMan) GetUserByEmail(email string) (User, error) {
var user User
r := DB.Where("email = ?", strings.ToLower(email)).First(&user)
return user, r.Error
}
// ListAdminEmails returns the array of email addresses of all admin users.
func ListAdminEmails() ([]string, error) {
func (m userMan) ListAdminEmails() ([]string, error) {
var (
users []User
emails []string
@ -106,14 +111,14 @@ func (u *User) VerifyPassword(password string) bool {
}
// FirstAdmin returns the admin user with the lowest ID number.
func FirstAdmin() (User, error) {
func (m userMan) FirstAdmin() (User, error) {
var user User
r := DB.First(&user, "is_admin", true)
return user, r.Error
}
// CreateUser adds a new user to the database.
func CreateUser(u User) error {
func (m userMan) CreateUser(u User) error {
if err := u.Validate(); err != nil {
return err
}

View File

@ -0,0 +1,37 @@
{{ define "title" }}Archive{{ end }}
{{ define "content" }}
<h1>Archive</h1>
{{ range .V.archive }}
<div class="card mb-4">
<div class="card-header">
<h3>{{ .Date.Format "January, 2006" }}</h3>
</div>
<div class="card-body">
<div class="row">
{{ range .Posts }}
<div class="col-12 col-sm-6 col-md-4 col-lg-3 mb-4">
<div class="card bg-secondary"
style="height: auto; min-height: 150px;
{{ if .Thumbnail }}background-image: url({{ .Thumbnail }}); background-size: cover{{ end }}
"
title="{{ .Title }}">
<span class="p-1" style="background-color: RGBA(255, 255, 255, 0.8)">
<a href="/{{ .Fragment }}">{{ .Title }}</a><br>
<small class="blog-meta">
{{ .CreatedAt.Format "Jan 02 2006" }}
{{ if ne .Privacy "public" }}
<span class="blog-{{ .Privacy }}">[{{ .Privacy }}]</span>
{{ end }}
</small>
</span>
</div>
</div>
{{ end }}
</div>
</div>
</div>
{{ end }}
{{ end }}