package web import ( "errors" "net/http" "strconv" "strings" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/slug" "github.com/gramanas/blogspace/internal/store" ) // Page slugs that would collide with blog routes, or with management routes on the root domain. var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true, "favicon.ico": true, "webadmin": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true, "tag": true, "search": true} func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { pages, err := blogStore(r).ListPages(r.Context()) if err != nil { s.serverError(w, err) return } s.render(w, r, "dashboard/pages.html", map[string]any{"pages": pages}) } // loadPage fetches the page named in the URL, or nil (having written the response) on failure. func (s *Server) loadPage(w http.ResponseWriter, r *http.Request) *store.Page { id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) p, err := blogStore(r).PageByID(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 } // pageKind reads a new page's kind from the form or query (?kind= on the // "New special page" link, a hidden field on the POST). An existing page // keeps its own. func pageKind(r *http.Request) string { return pick(r.FormValue("kind"), store.PageKindPosts, store.PageKindGallery) } // themeFiles are the images the design uses (logo, favicon, header and // background image): decoration, not content, so a gallery never shows them. func themeFiles(t Theme) []uuid.UUID { var ids []uuid.UUID for _, v := range []string{t.Logo, t.Favicon, t.HeaderImage, t.BgImage} { if id, err := uuid.Parse(v); err == nil { ids = append(ids, id) } } return ids } // pageFormData is what the page form needs besides the page. For a gallery // that is every library image but the theme's, and which ones the page // hides — an unsaved page hides none. func (s *Server) pageFormData(r *http.Request, p *store.Page) (map[string]any, error) { d := map[string]any{"page": p} if p.Kind != store.PageKindGallery { return d, nil } images, _, err := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0) if err != nil { return nil, err } skip := map[uuid.UUID]bool{} for _, id := range themeFiles(ParseTheme(currentBlog(r).ThemeJSON)) { skip[id] = true } shown := images[:0] for _, f := range images { if !skip[f.ID] { shown = append(shown, f) } } hidden := map[string]bool{} if p.ID != 0 { if hidden, err = blogStore(r).HiddenFiles(r.Context(), p.ID); err != nil { return nil, err } } d["images"], d["hidden"] = shown, hidden return d, nil } // hiddenFromForm is the "hide" checkboxes of a gallery form as ids; junk is dropped. func hiddenFromForm(r *http.Request) []uuid.UUID { ids := []uuid.UUID{} for _, v := range r.Form["hide"] { if id, err := uuid.Parse(v); err == nil { ids = append(ids, id) } } return ids } func (s *Server) handlePageForm(w http.ResponseWriter, r *http.Request) { p := &store.Page{Kind: pageKind(r), ShowInNav: true, ShowNotices: true} if r.PathValue("id") != "" { if p = s.loadPage(w, r); p == nil { return } } d, err := s.pageFormData(r, p) if err != nil { s.serverError(w, err) return } s.render(w, r, "dashboard/page_form.html", d) } func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) p := &store.Page{Kind: pageKind(r)} if r.PathValue("id") != "" { if p = s.loadPage(w, r); p == nil { return } } p.Title = strings.TrimSpace(r.FormValue("title")) p.Slug = strings.TrimSpace(r.FormValue("slug")) p.IntroMD = strings.ReplaceAll(r.FormValue("intro"), "\r\n", "\n") p.OutroMD = strings.ReplaceAll(r.FormValue("outro"), "\r\n", "\n") p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML) p.ShowInNav = r.FormValue("show_in_nav") == "on" p.ShowNotices = r.FormValue("show_notices") == "on" autoSlug := p.Slug == "" if autoSlug { p.Slug = slug.Make(p.Title) } img, err := s.readUpload(r, "intro_file", "") outroImg, err2 := s.readUpload(r, "outro_file", "") if err == nil { err = err2 } var msg string switch { case err != nil: msg = s.tr(r, "File not added:") + " " + err.Error() case p.Title == "" || len(p.Title) > 120: msg = s.tr(r, "Title is required (max 120 characters).") case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]: msg = s.tr(r, "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word).") } // The rejected form is rendered with what was sent, ticks included. fail := func(status int, msg string) { d, err := s.pageFormData(r, p) if err != nil { s.serverError(w, err) return } if p.Kind == store.PageKindGallery { hidden := map[string]bool{} for _, id := range hiddenFromForm(r) { hidden[id.String()] = true } d["hidden"] = hidden } d["error"] = msg s.renderStatus(w, r, status, "dashboard/page_form.html", d) } if msg != "" { fail(http.StatusBadRequest, msg) return } p.IntroMD = appendFile(p.IntroMD, img, p.Format) p.IntroHTML = renderBody(p.Format, p.IntroMD) p.OutroMD = appendFile(p.OutroMD, outroImg, p.Format) p.OutroHTML = renderBody(p.Format, p.OutroMD) 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.Page if created, err = blogStore(r).CreatePage(r.Context(), p); err == nil { p = created } } else { err = blogStore(r).UpdatePage(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 page with that slug already exists.")) return } s.serverError(w, err) return } if p.Kind == store.PageKindGallery { if err := blogStore(r).SetHiddenFiles(r.Context(), p.ID, hiddenFromForm(r)); err != nil { s.serverError(w, err) return } } redirectOK(w, r, "/b/"+blog.Subdomain+"/pages/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it.")) } func (s *Server) handlePageDeleteConfirm(w http.ResponseWriter, r *http.Request) { p := s.loadPage(w, r) if p == nil { return } what := s.trf(r, "the page “%s” and all %d of its posts", p.Title, p.PostCount) if p.Special() { what = s.trf(r, "the gallery page “%s”", p.Title) } s.render(w, r, "dashboard/confirm.html", map[string]any{ "what": what, "action": r.URL.Path, "back": "/b/" + currentBlog(r).Subdomain + "/pages", "isHome": p.IsHome, }) } func (s *Server) handlePageDelete(w http.ResponseWriter, r *http.Request) { p := s.loadPage(w, r) if p == nil { return } if p.IsHome { s.plainError(w, http.StatusBadRequest, s.tr(r, "The home page cannot be deleted. Make another page the home page first.")) return } if err := blogStore(r).DeletePage(r.Context(), p.ID); err != nil { s.serverError(w, err) return } redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", s.tr(r, "Page deleted.")) } func (s *Server) handlePageMove(w http.ResponseWriter, r *http.Request) { p := s.loadPage(w, r) if p == nil { return } dir := 1 if r.FormValue("dir") == "up" { dir = -1 } if err := blogStore(r).MovePage(r.Context(), p.ID, dir); err != nil { s.serverError(w, err) return } http.Redirect(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", http.StatusSeeOther) } func (s *Server) handlePageHome(w http.ResponseWriter, r *http.Request) { p := s.loadPage(w, r) if p == nil { return } if err := blogStore(r).SetHomePage(r.Context(), p.ID); err != nil { s.serverError(w, err) return } redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", s.trf(r, "“%s” is now the home page.", p.Title)) }