package web import ( "errors" "net/http" "strconv" "strings" "time" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/markdown" "github.com/gramanas/blogspace/internal/slug" "github.com/gramanas/blogspace/internal/store" ) // handlePreview renders Markdown for the editor's Preview toggle, with the same // pipeline a save uses; nothing is stored. Login, ownership, CSRF and the body // cap are all withBlog's. func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"html": markdown.Render(r.FormValue("body"))}) } func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) { pageID, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64) pages, err := blogStore(r).ListPages(r.Context()) if err != nil { s.serverError(w, err) return } posts, err := blogStore(r).ListPosts(r.Context(), pageID) if err != nil { s.serverError(w, err) return } s.render(w, r, "dashboard/posts.html", map[string]any{"posts": posts, "pages": pages, "pageID": pageID}) } func (s *Server) loadPost(w http.ResponseWriter, r *http.Request) *store.Post { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) p, err := blogStore(r).PostByID(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) } else { s.serverError(w, err) } return nil } return p } // postFormData is what the post form needs besides the post: the pages to // file it under, the tags to pick from, and the library images for the // featured-image picker (the recent ones; the chosen one is added by name). func (s *Server) postFormData(r *http.Request, p *store.Post) (map[string]any, error) { bs := blogStore(r) pages, err := bs.ListPages(r.Context()) if err != nil { return nil, err } allTags, err := bs.ListTags(r.Context()) if err != nil { return nil, err } images, imageCount, err := bs.ListFiles(r.Context(), "image", "", recentImages, 0) if err != nil { return nil, err } names := map[string]string{} for _, f := range images { names[f.ID.String()] = f.Filename } if p.Image != "" && names[p.Image] == "" { if f, err := bs.FileMeta(r.Context(), uuid.MustParse(p.Image)); err == nil { names[p.Image] = f.Filename } else { // gone from the library (or never there): the post has no image p.Image = "" } } return map[string]any{"post": p, "pages": pages, "allTags": allTags, "tagsText": tagsText(p.Tags), "images": images, "imageNames": names, "imageCount": imageCount}, nil } func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { p := &store.Post{Published: true, CreatedAt: time.Now()} if r.PathValue("id") != "" { if p = s.loadPost(w, r); p == nil { return } } else if pid, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64); pid != 0 { p.PageID = pid } else if hp, err := blogStore(r).HomePage(r.Context()); err == nil { p.PageID = hp.ID } d, err := s.postFormData(r, p) if err != nil { s.serverError(w, err) return } s.render(w, r, "dashboard/post_form.html", d) } func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) p := &store.Post{CreatedAt: time.Now()} if r.PathValue("id") != "" { if p = s.loadPost(w, r); p == nil { return } } p.Title = strings.TrimSpace(r.FormValue("title")) p.Slug = strings.TrimSpace(r.FormValue("slug")) p.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML) p.Published = r.FormValue("published") == "on" p.HideDate = r.FormValue("hide_date") == "on" p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) p.Image = fileID(pickFile(p.Image, r.FormValue("image"))) tags, tagsOK := parseTags(strings.Split(r.FormValue("tags"), ",")) p.Tags = tags autoSlug := p.Slug == "" if autoSlug { p.Slug = slug.Make(p.Title) } d, err := s.postFormData(r, p) if err != nil { s.serverError(w, err) return } pages := d["pages"].([]store.Page) fail := func(status int, msg string) { d["error"], d["tagsText"] = msg, r.FormValue("tags") s.renderStatus(w, r, status, "dashboard/post_form.html", d) } if at := strings.TrimSpace(r.FormValue("posted_at")); at != "" { // blank keeps the current date var ok bool if p.CreatedAt, ok = parsePostDate(at); !ok { fail(http.StatusBadRequest, s.tr(r, "Enter the date as YYYY-MM-DD HH:MM.")) return } } img, err := s.readUpload(r, "body_file", "") if err != nil { fail(http.StatusBadRequest, s.tr(r, "File not added:")+" "+err.Error()) return } p.BodyMD = appendFile(p.BodyMD, img, p.Format) if f, err := s.readUpload(r, "image_file", "image"); err != nil { // an upload beats the picker fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error()) return } else if f != nil { p.Image = f.ID.String() } pageOK := false for _, pg := range pages { if pg.ID == p.PageID { pageOK = true } } switch { case p.Title == "" || len(p.Title) > 200: fail(http.StatusBadRequest, s.tr(r, "Title is required (max 200 characters).")) return case !slug.Valid(p.Slug): fail(http.StatusBadRequest, s.tr(r, "Slug may only contain lowercase letters, digits and dashes.")) return case !pageOK: fail(http.StatusBadRequest, s.tr(r, "Pick a page for this post.")) return case len(p.BodyMD) > 200_000: fail(http.StatusBadRequest, s.tr(r, "Post is too long (200 KB max).")) return case !tagsOK: fail(http.StatusBadRequest, s.tr(r, "Tags: at most 20 per post, 40 characters each.")) return } p.BodyHTML = renderBody(p.Format, p.BodyMD) base := p.Slug for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict p.Slug = slug.WithSuffix(base, n) if p.ID == 0 { var created *store.Post if created, err = blogStore(r).CreatePost(r.Context(), p); err == nil { p = created } } else { err = blogStore(r).UpdatePost(r.Context(), p) } if !errors.Is(err, store.ErrConflict) || !autoSlug || n >= 50 { break } } if err != nil { if errors.Is(err, store.ErrConflict) { fail(http.StatusConflict, s.tr(r, "A post with that slug already exists on this page; choose another slug.")) return } s.serverError(w, err) return } if err := blogStore(r).SetPostTags(r.Context(), p.ID, tags); err != nil { s.serverError(w, err) return } redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it.")) } // parsePostDate reads the post form's date: what a datetime-local input sends, // or the same typed by hand where the browser shows a plain text box. The time // of day is optional. Times are taken in the server's zone, which is also the // zone the blog displays them in. func parsePostDate(v string) (time.Time, bool) { for _, layout := range []string{"2006-01-02T15:04", "2006-01-02 15:04", "2006-01-02T15:04:05", "2006-01-02"} { if t, err := time.ParseInLocation(layout, v, time.Local); err == nil { return t, true } } return time.Time{}, false } func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request) { p := s.loadPost(w, r) if p == nil { return } s.render(w, r, "dashboard/confirm.html", map[string]any{ "what": s.trf(r, "the post ā€œ%sā€", p.Title), "action": r.URL.Path, "back": "/b/" + currentBlog(r).Subdomain + "/posts", }) } func (s *Server) handlePostDelete(w http.ResponseWriter, r *http.Request) { p := s.loadPost(w, r) if p == nil { return } if err := blogStore(r).DeletePost(r.Context(), p.ID); err != nil { s.serverError(w, err) return } redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", s.tr(r, "Post deleted.")) }