diff --git a/cmd/gophertype/main.go b/cmd/gophertype/main.go index 0126791..d958f74 100644 --- a/cmd/gophertype/main.go +++ b/cmd/gophertype/main.go @@ -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 { diff --git a/cmd/legacy-import/main.go b/cmd/legacy-import/main.go index bddfd59..6af5394 100644 --- a/cmd/legacy-import/main.go +++ b/cmd/legacy-import/main.go @@ -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) + } + } +} diff --git a/go.mod b/go.mod index eff89db..da6afd8 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 6bc1432..ed569ea 100644 --- a/go.sum +++ b/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= diff --git a/pkg/authentication/login.go b/pkg/authentication/login.go index f3a98a2..f37be3b 100644 --- a/pkg/authentication/login.go +++ b/pkg/authentication/login.go @@ -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") } diff --git a/pkg/common/url_convert.go b/pkg/common/url_convert.go new file mode 100644 index 0000000..36fc847 --- /dev/null +++ b/pkg/common/url_convert.go @@ -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 +} diff --git a/pkg/controllers/authentication.go b/pkg/controllers/authentication.go index 9027d38..bda9d60 100644 --- a/pkg/controllers/authentication.go +++ b/pkg/controllers/authentication.go @@ -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 diff --git a/pkg/controllers/initial_setup.go b/pkg/controllers/initial_setup.go index 8924c04..13e57e7 100644 --- a/pkg/controllers/initial_setup.go +++ b/pkg/controllers/initial_setup.go @@ -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. diff --git a/pkg/controllers/posts.go b/pkg/controllers/posts.go index 10016e4..990d723 100644 --- a/pkg/controllers/posts.go +++ b/pkg/controllers/posts.go @@ -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) diff --git a/pkg/controllers/rss.go b/pkg/controllers/rss.go new file mode 100644 index 0000000..3215b04 --- /dev/null +++ b/pkg/controllers/rss.go @@ -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)) + } +} diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go index f20ef86..5d304d5 100644 --- a/pkg/mail/mail.go +++ b/pkg/mail/mail.go @@ -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) diff --git a/pkg/models/comments.go b/pkg/models/comments.go index 33d5c6f..e86e8e1 100644 --- a/pkg/models/comments.go +++ b/pkg/models/comments.go @@ -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 } } diff --git a/pkg/models/models.go b/pkg/models/models.go index 32cef81..719e025 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -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{}) } diff --git a/pkg/models/posts.go b/pkg/models/posts.go index 2358664..ded3d56 100644 --- a/pkg/models/posts.go +++ b/pkg/models/posts.go @@ -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 } } diff --git a/pkg/models/tags.go b/pkg/models/tags.go index d5c7abb..d9ba9f6 100644 --- a/pkg/models/tags.go +++ b/pkg/models/tags.go @@ -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 } diff --git a/pkg/models/users.go b/pkg/models/users.go index 9d981b4..265aaa1 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -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 } diff --git a/pvt-www/_builtin/blog/archive.gohtml b/pvt-www/_builtin/blog/archive.gohtml new file mode 100644 index 0000000..1411c24 --- /dev/null +++ b/pvt-www/_builtin/blog/archive.gohtml @@ -0,0 +1,37 @@ +{{ define "title" }}Archive{{ end }} +{{ define "content" }} + +

Archive

+ +{{ range .V.archive }} +
+
+

{{ .Date.Format "January, 2006" }}

+
+
+
+ {{ range .Posts }} +
+
+ + {{ .Title }}
+ + {{ .CreatedAt.Format "Jan 02 2006" }} + {{ if ne .Privacy "public" }} + [{{ .Privacy }}] + {{ end }} + +
+
+
+ {{ end }} +
+
+
+{{ end }} + +{{ end }}