From 4f01edb9230f58ff84b0dd892c931ec8ac9aad55 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Tue, 13 Sep 2022 12:56:08 +0200 Subject: move src out of srv, clean up default.nix and Makefile --- src/http/posts.go | 421 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 src/http/posts.go (limited to 'src/http/posts.go') diff --git a/src/http/posts.go b/src/http/posts.go new file mode 100644 index 0000000..c3f6363 --- /dev/null +++ b/src/http/posts.go @@ -0,0 +1,421 @@ +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "net/http" + "path/filepath" + "strings" + txttpl "text/template" + "time" + + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil" + "github.com/mediocregopher/blog.mediocregopher.com/srv/post" + "github.com/mediocregopher/mediocre-go-lib/v2/mctx" +) + +func (a *api) parsePostBody(post post.Post) (*txttpl.Template, error) { + tpl := txttpl.New("root") + tpl = tpl.Funcs(txttpl.FuncMap(a.tplFuncs())) + + tpl = txttpl.Must(tpl.New("image.html").Parse(mustReadTplFile("image.html"))) + tpl = tpl.Funcs(txttpl.FuncMap{ + "Image": func(id string) (string, error) { + + tplPayload := struct { + ID string + Resizable bool + }{ + ID: id, + Resizable: isImgResizable(id), + } + + buf := new(bytes.Buffer) + if err := tpl.ExecuteTemplate(buf, "image.html", tplPayload); err != nil { + return "", err + } + + return buf.String(), nil + }, + }) + + tpl, err := tpl.New(post.ID + "-body.html").Parse(post.Body) + + if err != nil { + return nil, err + } + + return tpl, nil +} + +type postTplPayload struct { + post.StoredPost + SeriesPrevious, SeriesNext *post.StoredPost + Body template.HTML +} + +func (a *api) postToPostTplPayload(storedPost post.StoredPost) (postTplPayload, error) { + + bodyTpl, err := a.parsePostBody(storedPost.Post) + if err != nil { + return postTplPayload{}, fmt.Errorf("parsing post body as template: %w", err) + } + + bodyBuf := new(bytes.Buffer) + + if err := bodyTpl.Execute(bodyBuf, nil); err != nil { + return postTplPayload{}, fmt.Errorf("executing post body as template: %w", err) + } + + // this helps the markdown renderer properly parse pages which end in a + // `` tag... I don't know why. + _, _ = bodyBuf.WriteString("\n") + + parserExt := parser.CommonExtensions | parser.AutoHeadingIDs + parser := parser.NewWithExtensions(parserExt) + + htmlFlags := html.HrefTargetBlank + htmlRenderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags}) + + renderedBody := markdown.ToHTML(bodyBuf.Bytes(), parser, htmlRenderer) + + tplPayload := postTplPayload{ + StoredPost: storedPost, + Body: template.HTML(renderedBody), + } + + if series := storedPost.Series; series != "" { + + seriesPosts, err := a.params.PostStore.GetBySeries(series) + if err != nil { + return postTplPayload{}, fmt.Errorf( + "fetching posts for series %q: %w", series, err, + ) + } + + var foundThis bool + + for i := range seriesPosts { + + seriesPost := seriesPosts[i] + + if seriesPost.ID == storedPost.ID { + foundThis = true + continue + } + + if !foundThis { + tplPayload.SeriesNext = &seriesPost + continue + } + + tplPayload.SeriesPrevious = &seriesPost + break + } + } + + return tplPayload, nil +} + +func (a *api) renderPostHandler() http.Handler { + + tpl := a.mustParseBasedTpl("post.html") + renderPostsIndexHandler := a.renderPostsIndexHandler() + renderEditPostHandler := a.renderEditPostHandler(false) + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + id := strings.TrimSuffix(filepath.Base(r.URL.Path), ".html") + + if id == "/" { + renderPostsIndexHandler.ServeHTTP(rw, r) + return + } + + if _, ok := r.URL.Query()["edit"]; ok { + renderEditPostHandler.ServeHTTP(rw, r) + return + } + + storedPost, err := a.params.PostStore.GetByID(id) + + if errors.Is(err, post.ErrPostNotFound) { + http.Error(rw, "Post not found", 404) + return + } else if err != nil { + apiutil.InternalServerError( + rw, r, fmt.Errorf("fetching post with id %q: %w", id, err), + ) + return + } + + tplPayload, err := a.postToPostTplPayload(storedPost) + + if err != nil { + apiutil.InternalServerError( + rw, r, fmt.Errorf( + "generating template payload for post with id %q: %w", + id, err, + ), + ) + return + } + + executeTemplate(rw, r, tpl, tplPayload) + }) +} + +func (a *api) renderPostsIndexHandler() http.Handler { + + renderEditPostHandler := a.renderEditPostHandler(false) + tpl := a.mustParseBasedTpl("posts.html") + const pageCount = 20 + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + if _, ok := r.URL.Query()["edit"]; ok { + renderEditPostHandler.ServeHTTP(rw, r) + return + } + + page, err := apiutil.StrToInt(r.FormValue("p"), 0) + if err != nil { + apiutil.BadRequest( + rw, r, fmt.Errorf("invalid page number: %w", err), + ) + return + } + + posts, hasMore, err := a.params.PostStore.Get(page, pageCount) + if err != nil { + apiutil.InternalServerError( + rw, r, fmt.Errorf("fetching page %d of posts: %w", page, err), + ) + return + } + + tplPayload := struct { + Posts []post.StoredPost + PrevPage, NextPage int + }{ + Posts: posts, + PrevPage: -1, + NextPage: -1, + } + + if page > 0 { + tplPayload.PrevPage = page - 1 + } + + if hasMore { + tplPayload.NextPage = page + 1 + } + + executeTemplate(rw, r, tpl, tplPayload) + }) +} + +func (a *api) renderEditPostHandler(isDraft bool) http.Handler { + + tpl := a.mustParseBasedTpl("edit-post.html") + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + id := filepath.Base(r.URL.Path) + + var storedPost post.StoredPost + + if id != "/" { + + var err error + + if isDraft { + storedPost.Post, err = a.params.PostDraftStore.GetByID(id) + } else { + storedPost, err = a.params.PostStore.GetByID(id) + } + + if errors.Is(err, post.ErrPostNotFound) { + http.Error(rw, "Post not found", 404) + return + } else if err != nil { + apiutil.InternalServerError( + rw, r, fmt.Errorf("fetching post with id %q: %w", id, err), + ) + return + } + + } else if !isDraft { + http.Error(rw, "Post ID required in URL", 400) + return + } + + tags, err := a.params.PostStore.GetTags() + if err != nil { + apiutil.InternalServerError(rw, r, fmt.Errorf("fetching tags: %w", err)) + return + } + + tplPayload := struct { + Post post.StoredPost + Tags []string + IsDraft bool + }{ + Post: storedPost, + Tags: tags, + IsDraft: isDraft, + } + + executeTemplate(rw, r, tpl, tplPayload) + }) +} + +func postFromPostReq(r *http.Request) (post.Post, error) { + + p := post.Post{ + ID: r.PostFormValue("id"), + Title: r.PostFormValue("title"), + Description: r.PostFormValue("description"), + Tags: strings.Fields(r.PostFormValue("tags")), + Series: r.PostFormValue("series"), + } + + // textareas encode newlines as CRLF for historical reasons + p.Body = r.PostFormValue("body") + p.Body = strings.ReplaceAll(p.Body, "\r\n", "\n") + p.Body = strings.TrimSpace(p.Body) + + if p.ID == "" || + p.Title == "" || + p.Description == "" || + p.Body == "" || + len(p.Tags) == 0 { + return post.Post{}, errors.New("ID, Title, Description, Tags, and Body are all required") + } + + return p, nil +} + +func (a *api) storeAndPublishPost(ctx context.Context, p post.Post) error { + + first, err := a.params.PostStore.Set(p, time.Now()) + + if err != nil { + return fmt.Errorf("storing post with id %q: %w", p.ID, err) + } + + if !first { + return nil + } + + a.params.Logger.Info(ctx, "publishing blog post to mailing list") + urlStr := a.postURL(p.ID, true) + + if err := a.params.MailingList.Publish(p.Title, urlStr); err != nil { + return fmt.Errorf("publishing post to mailing list: %w", err) + } + + if err := a.params.PostDraftStore.Delete(p.ID); err != nil { + return fmt.Errorf("deleting draft: %w", err) + } + + return nil +} + +func (a *api) postPostHandler() http.Handler { + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + p, err := postFromPostReq(r) + if err != nil { + apiutil.BadRequest(rw, r, err) + return + } + + ctx = mctx.Annotate(ctx, "postID", p.ID) + + if err := a.storeAndPublishPost(ctx, p); err != nil { + apiutil.InternalServerError( + rw, r, fmt.Errorf("storing/publishing post with id %q: %w", p.ID, err), + ) + return + } + + a.executeRedirectTpl(rw, r, a.postURL(p.ID, false)) + }) +} + +func (a *api) deletePostHandler(isDraft bool) http.Handler { + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + id := filepath.Base(r.URL.Path) + + if id == "/" { + apiutil.BadRequest(rw, r, errors.New("id is required")) + return + } + + var err error + + if isDraft { + err = a.params.PostDraftStore.Delete(id) + } else { + err = a.params.PostStore.Delete(id) + } + + if errors.Is(err, post.ErrPostNotFound) { + http.Error(rw, "Post not found", 404) + return + } else if err != nil { + apiutil.InternalServerError( + rw, r, fmt.Errorf("deleting post with id %q: %w", id, err), + ) + return + } + + if isDraft { + a.executeRedirectTpl(rw, r, a.draftsURL(false)) + } else { + a.executeRedirectTpl(rw, r, a.postsURL(false)) + } + }) +} + +func (a *api) previewPostHandler() http.Handler { + + tpl := a.mustParseBasedTpl("post.html") + + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + p, err := postFromPostReq(r) + if err != nil { + apiutil.BadRequest(rw, r, err) + return + } + + storedPost := post.StoredPost{ + Post: p, + PublishedAt: time.Now(), + } + + tplPayload, err := a.postToPostTplPayload(storedPost) + + if err != nil { + apiutil.InternalServerError( + rw, r, fmt.Errorf("generating template payload: %w", err), + ) + return + } + + executeTemplate(rw, r, tpl, tplPayload) + }) +} -- cgit v1.2.3