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:
parent
383b5d7591
commit
5b6712ea97
|
@ -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 {
|
||||
|
|
|
@ -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
1
go.mod
|
@ -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
1
go.sum
|
@ -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=
|
||||
|
|
|
@ -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
53
pkg/common/url_convert.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
105
pkg/controllers/rss.go
Normal 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))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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{})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
37
pvt-www/_builtin/blog/archive.gohtml
Normal file
37
pvt-www/_builtin/blog/archive.gohtml
Normal 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 }}
|
Loading…
Reference in New Issue
Block a user