package web import ( "errors" "net/http" "strconv" "strings" "github.com/gramanas/blogspace/internal/markdown" "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 } func (s *Server) handlePageForm(w http.ResponseWriter, r *http.Request) { p := &store.Page{ShowInNav: true} if r.PathValue("id") != "" { if p = s.loadPage(w, r); p == nil { return } } s.render(w, r, "dashboard/page_form.html", map[string]any{"page": p}) } func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) p := &store.Page{} 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.ShowInNav = r.FormValue("show_in_nav") == "on" autoSlug := p.Slug == "" if autoSlug { p.Slug = slug.Make(p.Title) } img, err := s.readUpload(r, "inline_file", false) 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).") } if msg != "" { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg}) return } p.IntroMD = appendFileMD(p.IntroMD, img) p.IntroHTML = markdown.Render(p.IntroMD) 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) { s.renderStatus(w, r, http.StatusConflict, "dashboard/page_form.html", map[string]any{"page": p, "error": s.tr(r, "A page with that slug already exists.")}) return } 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 } s.render(w, r, "dashboard/confirm.html", map[string]any{ "what": s.trf(r, "the page “%s” and all %d of its posts", p.Title, p.PostCount), "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)) }