From 1f4dd335c575e86b067f85d9c5cf0f17c3c04930 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Fri, 30 Jul 2021 18:08:42 -0500 Subject: [PATCH] Add all the rest of the blog post operations Still need to add UI for the blog index, and fix some aesthetic issues: - Wide posts can break the editor UI - Blog comments don't show the fancy reply UI - The post hash stuff on blog threads doesn't jump you to the correct post Probably other stuff, I dunno. --- src/hmnurl/urls.go | 7 + src/models/thread.go | 4 +- src/templates/src/blog_post_delete.html | 12 ++ src/website/blogs.go | 204 +++++++++++++++++++++++- src/website/feed.go | 4 +- src/website/forums.go | 31 ++-- src/website/landing.go | 6 +- src/website/post_helper.go | 8 +- src/website/routes.go | 10 +- src/website/subforum_helper.go | 4 +- src/website/threads_and_posts_helper.go | 2 +- src/website/timeline_helper.go | 6 +- src/website/urls.go | 2 +- 13 files changed, 266 insertions(+), 34 deletions(-) create mode 100644 src/templates/src/blog_post_delete.html diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 1e223eb3..0472719d 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -428,6 +428,13 @@ func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId)) } +var RegexBlogNewThread = regexp.MustCompile(`^/blog/new$`) + +func BuildBlogNewThread(projectSlug string) string { + defer CatchPanic() + return ProjectUrl("/blog/new", nil, projectSlug) +} + var RegexBlogPost = regexp.MustCompile(`^/blog/p/(?P\d+)/e/(?P\d+)$`) func BuildBlogPost(projectSlug string, threadId int, postId int) string { diff --git a/src/models/thread.go b/src/models/thread.go index f35a2ae6..36f327a9 100644 --- a/src/models/thread.go +++ b/src/models/thread.go @@ -3,13 +3,13 @@ package models type ThreadType int const ( - ThreadTypeProjectArticle ThreadType = iota + 1 + ThreadTypeProjectBlogPost ThreadType = iota + 1 ThreadTypeForumPost _ // formerly occupied by static pages, RIP _ // formerly occupied by who the hell knows what, RIP _ // formerly occupied by the wiki, RIP _ // formerly occupied by library discussions, RIP - ThreadTypePersonalArticle + ThreadTypePersonalBlogPost ) type Thread struct { diff --git a/src/templates/src/blog_post_delete.html b/src/templates/src/blog_post_delete.html new file mode 100644 index 00000000..bd650607 --- /dev/null +++ b/src/templates/src/blog_post_delete.html @@ -0,0 +1,12 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+

Are you sure you want to delete this post?

+ {{ template "forum_post_standalone.html" .Post }} +
+ {{ csrftoken .Session }} + +
+
+{{ end }} diff --git a/src/website/blogs.go b/src/website/blogs.go index 7d0f2fba..f14f0e67 100644 --- a/src/website/blogs.go +++ b/src/website/blogs.go @@ -54,10 +54,12 @@ func BlogThread(c *RequestContext) ResponseData { var res ResponseData res.MustWriteTemplate("blog_post.html", blogPostData{ - BaseData: baseData, - Thread: templates.ThreadToTemplate(&thread), - MainPost: templatePosts[0], - Comments: templatePosts[1:], + BaseData: baseData, + Thread: templates.ThreadToTemplate(&thread), + MainPost: templatePosts[0], + Comments: templatePosts[1:], + ReplyLink: hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, posts[0].Post.ID), + LoginLink: hmnurl.BuildLoginPage(c.FullUrl()), }, c.Perf) return res } @@ -74,6 +76,71 @@ func BlogPostRedirectToThread(c *RequestContext) ResponseData { return c.Redirect(threadUrl, http.StatusFound) } +func BlogNewThread(c *RequestContext) ResponseData { + baseData := getBaseData(c) + baseData.Title = fmt.Sprintf("Create New Post | %s", c.CurrentProject.Name) + baseData.MathjaxEnabled = true + // TODO(ben): Set breadcrumbs + + editData := getEditorDataForNew(baseData, nil) + editData.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug) + editData.SubmitLabel = "Create Post" + + var res ResponseData + res.MustWriteTemplate("editor.html", editData, c.Perf) + return res +} + +func BlogNewThreadSubmit(c *RequestContext) ResponseData { + tx, err := c.Conn.Begin(c.Context()) + if err != nil { + panic(err) + } + defer tx.Rollback(c.Context()) + + err = c.Req.ParseForm() + if err != nil { + return ErrorResponse(http.StatusBadRequest, oops.New(err, "the form data was invalid")) + } + title := c.Req.Form.Get("title") + unparsed := c.Req.Form.Get("body") + if title == "" { + return RejectRequest(c, "You must provide a title for your post.") + } + if unparsed == "" { + return RejectRequest(c, "You must provide a body for your post.") + } + + // Create thread + var threadId int + err = tx.QueryRow(c.Context(), + ` + INSERT INTO handmade_thread (title, type, project_id, first_id, last_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, + title, + models.ThreadTypeProjectBlogPost, + c.CurrentProject.ID, + -1, + -1, + ).Scan(&threadId) + if err != nil { + panic(oops.New(err, "failed to create thread")) + } + + // Create everything else + CreateNewPost(c.Context(), tx, c.CurrentProject.ID, threadId, models.ThreadTypeProjectBlogPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host) + + err = tx.Commit(c.Context()) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new blog post")) + } + + newThreadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, threadId, title) + return c.Redirect(newThreadUrl, http.StatusSeeOther) +} + func BlogPostEdit(c *RequestContext) ResponseData { cd, ok := getCommonBlogData(c) if !ok { @@ -147,6 +214,135 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData { return c.Redirect(postUrl, http.StatusSeeOther) } +func BlogPostReply(c *RequestContext) ResponseData { + cd, ok := getCommonBlogData(c) + if !ok { + return FourOhFour(c) + } + + postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID) + + baseData := getBaseData(c) + baseData.Title = fmt.Sprintf("Replying to comment in \"%s\" | %s", postData.Thread.Title, c.CurrentProject.Name) + baseData.MathjaxEnabled = true + // TODO(ben): Set breadcrumbs + + replyPost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) + replyPost.AddContentVersion(postData.CurrentVersion, postData.Editor) + + editData := getEditorDataForNew(baseData, &replyPost) + editData.SubmitUrl = hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, cd.PostID) + editData.SubmitLabel = "Submit Reply" + + var res ResponseData + res.MustWriteTemplate("editor.html", editData, c.Perf) + return res +} + +func BlogPostReplySubmit(c *RequestContext) ResponseData { + cd, ok := getCommonBlogData(c) + if !ok { + return FourOhFour(c) + } + + tx, err := c.Conn.Begin(c.Context()) + if err != nil { + panic(err) + } + defer tx.Rollback(c.Context()) + + err = c.Req.ParseForm() + if err != nil { + return ErrorResponse(http.StatusBadRequest, oops.New(nil, "the form data was invalid")) + } + unparsed := c.Req.Form.Get("body") + if unparsed == "" { + return RejectRequest(c, "Your reply cannot be empty.") + } + + newPostId, _ := CreateNewPost(c.Context(), tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeProjectBlogPost, c.CurrentUser.ID, &cd.PostID, unparsed, c.Req.Host) + + err = tx.Commit(c.Context()) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to blog post")) + } + + newPostUrl := hmnurl.BuildBlogPost(c.CurrentProject.Slug, cd.ThreadID, newPostId) + return c.Redirect(newPostUrl, http.StatusSeeOther) +} + +func BlogPostDelete(c *RequestContext) ResponseData { + cd, ok := getCommonBlogData(c) + if !ok { + return FourOhFour(c) + } + + if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) { + return FourOhFour(c) + } + + postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID) + + baseData := getBaseData(c) + if postData.Thread.FirstID == postData.Post.ID { + baseData.Title = fmt.Sprintf("Deleting \"%s\" | %s", postData.Thread.Title, c.CurrentProject.Name) + } else { + baseData.Title = fmt.Sprintf("Deleting comment in \"%s\" | %s", postData.Thread.Title, c.CurrentProject.Name) + } + baseData.MathjaxEnabled = true + // TODO(ben): Set breadcrumbs + + templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) + templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor) + + type blogPostDeleteData struct { + templates.BaseData + Post templates.Post + SubmitUrl string + } + + var res ResponseData + res.MustWriteTemplate("blog_post_delete.html", blogPostDeleteData{ + BaseData: baseData, + SubmitUrl: hmnurl.BuildBlogPostDelete(c.CurrentProject.Slug, cd.ThreadID, cd.PostID), + Post: templatePost, + }, c.Perf) + return res +} + +func BlogPostDeleteSubmit(c *RequestContext) ResponseData { + cd, ok := getCommonBlogData(c) + if !ok { + return FourOhFour(c) + } + + if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) { + return FourOhFour(c) + } + + tx, err := c.Conn.Begin(c.Context()) + if err != nil { + panic(err) + } + defer tx.Rollback(c.Context()) + + threadDeleted := DeletePost(c.Context(), tx, cd.ThreadID, cd.PostID) + + err = tx.Commit(c.Context()) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post")) + } + + if threadDeleted { + projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug) + return c.Redirect(projectUrl, http.StatusSeeOther) + } else { + thread := FetchThread(c.Context(), c.Conn, cd.ThreadID) + threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, thread.ID, thread.Title) + return c.Redirect(threadUrl, http.StatusSeeOther) + } +} + type commonBlogData struct { c *RequestContext diff --git a/src/website/feed.go b/src/website/feed.go index 2c2db92a..9e1dca7e 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -41,7 +41,7 @@ func Feed(c *RequestContext) ResponseData { AND deleted = FALSE AND post.thread_id IS NOT NULL `, - []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle}, + []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectBlogPost}, ) c.Perf.EndBlock() if err != nil { @@ -339,7 +339,7 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.SubforumLineageBuil LIMIT $3 OFFSET $4 `, currentUserID, - []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle}, + []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectBlogPost}, limit, offset, ) diff --git a/src/website/forums.go b/src/website/forums.go index 3ca9d880..1c6dd893 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -634,16 +634,22 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData { return FourOhFour(c) } - c.Req.ParseForm() - + err = c.Req.ParseForm() + if err != nil { + return ErrorResponse(http.StatusBadRequest, oops.New(err, "the form data was invalid")) + } title := c.Req.Form.Get("title") unparsed := c.Req.Form.Get("body") sticky := false if c.CurrentUser.IsStaff && c.Req.Form.Get("sticky") != "" { sticky = true } - - // TODO(ben): Validation (and error handling if ParseForm fails? might not need it since you'll get empty values) + if title == "" { + return RejectRequest(c, "You must provide a title for your post.") + } + if unparsed == "" { + return RejectRequest(c, "You must provide a body for your post.") + } // Create thread var threadId int @@ -686,14 +692,14 @@ func ForumPostReply(c *RequestContext) ResponseData { postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID) baseData := getBaseData(c) - baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name) + baseData.Title = fmt.Sprintf("Replying to post | %s", cd.SubforumTree[cd.SubforumID].Name) baseData.MathjaxEnabled = true // TODO(ben): Set breadcrumbs - templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) - templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor) + replyPost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) + replyPost.AddContentVersion(postData.CurrentVersion, postData.Editor) - editData := getEditorDataForNew(baseData, &templatePost) + editData := getEditorDataForNew(baseData, &replyPost) editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID) editData.SubmitLabel = "Submit Reply" @@ -714,9 +720,14 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData { } defer tx.Rollback(c.Context()) - c.Req.ParseForm() - // TODO(ben): Validation + err = c.Req.ParseForm() + if err != nil { + return ErrorResponse(http.StatusBadRequest, oops.New(nil, "the form data was invalid")) + } unparsed := c.Req.Form.Get("body") + if unparsed == "" { + return RejectRequest(c, "Your reply cannot be empty.") + } newPostId, _ := CreateNewPost(c.Context(), tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeForumPost, c.CurrentUser.ID, &cd.PostID, unparsed, c.Req.Host) diff --git a/src/website/landing.go b/src/website/landing.go index 1130b958..531d428e 100644 --- a/src/website/landing.go +++ b/src/website/landing.go @@ -120,7 +120,7 @@ func Index(c *RequestContext) ResponseData { `, currentUserId, proj.ID, - []models.ThreadType{models.ThreadTypeProjectArticle, models.ThreadTypeForumPost}, + []models.ThreadType{models.ThreadTypeProjectBlogPost, models.ThreadTypeForumPost}, maxPosts, ) c.Perf.EndBlock() @@ -155,7 +155,7 @@ func Index(c *RequestContext) ResponseData { } featurable := (!proj.IsHMN() && - projectPost.Post.ThreadType == models.ThreadTypeProjectArticle && + projectPost.Post.ThreadType == models.ThreadTypeProjectBlogPost && projectPost.Thread.FirstID == projectPost.Post.ID && landingPageProject.FeaturedPost == nil) @@ -269,7 +269,7 @@ func Index(c *RequestContext) ResponseData { LIMIT 1 `, models.HMNProjectID, - models.ThreadTypeProjectArticle, + models.ThreadTypeProjectBlogPost, ) if err != nil { return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post")) diff --git a/src/website/post_helper.go b/src/website/post_helper.go index 52b27e5c..ce368d26 100644 --- a/src/website/post_helper.go +++ b/src/website/post_helper.go @@ -9,7 +9,7 @@ import ( // NOTE(asaf): Please don't use this if you already know the kind of the post beforehand. Just call the appropriate build function. func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string { switch post.ThreadType { - case models.ThreadTypeProjectArticle: + case models.ThreadTypeProjectBlogPost: return hmnurl.BuildBlogThreadWithPostHash(projectSlug, post.ThreadID, thread.Title, post.ID) case models.ThreadTypeForumPost: return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID) @@ -19,9 +19,9 @@ func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder } var PostTypeMap = map[models.ThreadType][]templates.PostType{ - // { First post , Subsequent post } - models.ThreadTypeProjectArticle: {templates.PostTypeBlogPost, templates.PostTypeBlogComment}, - models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply}, + // { First post , Subsequent post } + models.ThreadTypeProjectBlogPost: {templates.PostTypeBlogPost, templates.PostTypeBlogComment}, + models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply}, } var PostTypePrefix = map[templates.PostType]string{ diff --git a/src/website/routes.go b/src/website/routes.go index 85771126..c242928e 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -166,10 +166,16 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete)) mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit))) + mainRoutes.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread)) + mainRoutes.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit))) mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread) mainRoutes.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread) - mainRoutes.GET(hmnurl.RegexBlogPostEdit, BlogPostEdit) - mainRoutes.POST(hmnurl.RegexBlogPostEdit, BlogPostEditSubmit) + mainRoutes.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply)) + mainRoutes.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit))) + mainRoutes.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit)) + mainRoutes.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit))) + mainRoutes.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete)) + mainRoutes.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit))) mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex) mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit) diff --git a/src/website/subforum_helper.go b/src/website/subforum_helper.go index 1e8c13c0..f15def2e 100644 --- a/src/website/subforum_helper.go +++ b/src/website/subforum_helper.go @@ -5,6 +5,6 @@ import ( ) var ThreadTypeDisplayNames = map[models.ThreadType]string{ - models.ThreadTypeProjectArticle: "Blog", - models.ThreadTypeForumPost: "Forums", + models.ThreadTypeProjectBlogPost: "Blog", + models.ThreadTypeForumPost: "Forums", } diff --git a/src/website/threads_and_posts_helper.go b/src/website/threads_and_posts_helper.go index 49f622a4..3235d1d0 100644 --- a/src/website/threads_and_posts_helper.go +++ b/src/website/threads_and_posts_helper.go @@ -241,7 +241,7 @@ func CreateNewPost( `, time.Now(), threadId, - models.ThreadTypeForumPost, + threadType, -1, userId, projectId, diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go index a13d719c..b8e00c5a 100644 --- a/src/website/timeline_helper.go +++ b/src/website/timeline_helper.go @@ -11,9 +11,9 @@ import ( ) var TimelineTypeMap = map[models.ThreadType][]templates.TimelineType{ - // { First post , Subsequent post } - models.ThreadTypeProjectArticle: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment}, - models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply}, + // { First post , Subsequent post } + models.ThreadTypeProjectBlogPost: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment}, + models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply}, } var TimelineItemClassMap = map[templates.TimelineType]string{ diff --git a/src/website/urls.go b/src/website/urls.go index f7955f68..6c86f9b4 100644 --- a/src/website/urls.go +++ b/src/website/urls.go @@ -7,7 +7,7 @@ import ( func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string { switch kind { - case models.ThreadTypeProjectArticle: + case models.ThreadTypeProjectBlogPost: return hmnurl.BuildBlog(projectSlug, 1) case models.ThreadTypeForumPost: return hmnurl.BuildForum(projectSlug, nil, 1)