From 3eb04b1a2bdf9e53231fe862cfd76327371a9741 Mon Sep 17 00:00:00 2001 From: gramanas Date: Sat, 12 Sep 2026 11:24:17 +0300 Subject: Initial multi-tenant blog host Go + Postgres application serving a management dashboard on the base domain and one public blog per subdomain. Markdown posts organised in pages, form-based theme customisation, image uploads stored in Postgres, JWT cookie sessions with CSRF, superadmin user management, RSS feeds. Docker/compose deployment and a Makefile-driven dev environment with seed data. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_admin.go | 154 +++++++++++++++ internal/web/handlers_auth.go | 131 +++++++++++++ internal/web/handlers_blog.go | 150 ++++++++++++++ internal/web/handlers_dashboard.go | 43 ++++ internal/web/handlers_design.go | 155 +++++++++++++++ internal/web/handlers_media.go | 38 ++++ internal/web/handlers_pages.go | 161 +++++++++++++++ internal/web/handlers_posts.go | 158 +++++++++++++++ internal/web/routes.go | 81 ++++++++ internal/web/server.go | 251 ++++++++++++++++++++++++ internal/web/static/blog.css | 50 +++++ internal/web/static/dashboard.css | 87 ++++++++ internal/web/templates.go | 117 +++++++++++ internal/web/templates/admin/delete_user.html | 12 ++ internal/web/templates/admin/index.html | 28 +++ internal/web/templates/admin/new_user.html | 15 ++ internal/web/templates/auth/login.html | 12 ++ internal/web/templates/blog/404.html | 5 + internal/web/templates/blog/page.html | 19 ++ internal/web/templates/blog/post.html | 8 + internal/web/templates/dashboard/confirm.html | 17 ++ internal/web/templates/dashboard/design.html | 87 ++++++++ internal/web/templates/dashboard/images.html | 22 +++ internal/web/templates/dashboard/overview.html | 35 ++++ internal/web/templates/dashboard/page_form.html | 21 ++ internal/web/templates/dashboard/pages.html | 24 +++ internal/web/templates/dashboard/password.html | 13 ++ internal/web/templates/dashboard/post_form.html | 26 +++ internal/web/templates/dashboard/posts.html | 25 +++ internal/web/templates/dashboard/settings.html | 13 ++ internal/web/templates/layouts/blog.html | 38 ++++ internal/web/templates/layouts/dashboard.html | 17 ++ internal/web/templates/partials/dashnav.html | 27 +++ internal/web/templates/partials/imagepick.html | 11 ++ internal/web/templates/partials/mdhelp.html | 9 + internal/web/theme.go | 212 ++++++++++++++++++++ internal/web/web_test.go | 73 +++++++ 37 files changed, 2345 insertions(+) create mode 100644 internal/web/handlers_admin.go create mode 100644 internal/web/handlers_auth.go create mode 100644 internal/web/handlers_blog.go create mode 100644 internal/web/handlers_dashboard.go create mode 100644 internal/web/handlers_design.go create mode 100644 internal/web/handlers_media.go create mode 100644 internal/web/handlers_pages.go create mode 100644 internal/web/handlers_posts.go create mode 100644 internal/web/routes.go create mode 100644 internal/web/server.go create mode 100644 internal/web/static/blog.css create mode 100644 internal/web/static/dashboard.css create mode 100644 internal/web/templates.go create mode 100644 internal/web/templates/admin/delete_user.html create mode 100644 internal/web/templates/admin/index.html create mode 100644 internal/web/templates/admin/new_user.html create mode 100644 internal/web/templates/auth/login.html create mode 100644 internal/web/templates/blog/404.html create mode 100644 internal/web/templates/blog/page.html create mode 100644 internal/web/templates/blog/post.html create mode 100644 internal/web/templates/dashboard/confirm.html create mode 100644 internal/web/templates/dashboard/design.html create mode 100644 internal/web/templates/dashboard/images.html create mode 100644 internal/web/templates/dashboard/overview.html create mode 100644 internal/web/templates/dashboard/page_form.html create mode 100644 internal/web/templates/dashboard/pages.html create mode 100644 internal/web/templates/dashboard/password.html create mode 100644 internal/web/templates/dashboard/post_form.html create mode 100644 internal/web/templates/dashboard/posts.html create mode 100644 internal/web/templates/dashboard/settings.html create mode 100644 internal/web/templates/layouts/blog.html create mode 100644 internal/web/templates/layouts/dashboard.html create mode 100644 internal/web/templates/partials/dashnav.html create mode 100644 internal/web/templates/partials/imagepick.html create mode 100644 internal/web/templates/partials/mdhelp.html create mode 100644 internal/web/theme.go create mode 100644 internal/web/web_test.go (limited to 'internal/web') diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go new file mode 100644 index 0000000..30fdc2e --- /dev/null +++ b/internal/web/handlers_admin.go @@ -0,0 +1,154 @@ +package web + +import ( + "errors" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/store" +) + +var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]{2,40}$`) +var subdomainRe = regexp.MustCompile(`^[a-z0-9](-?[a-z0-9]){0,62}$`) + +func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { + users, err := s.st.ListUsers(r.Context()) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "admin/index.html", map[string]any{"users": users, "cfg": s.cfg}) +} + +func (s *Server) handleAdminNewUserForm(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "admin/new_user.html", map[string]any{"cfg": s.cfg}) +} + +func (s *Server) handleAdminNewUser(w http.ResponseWriter, r *http.Request) { + f := map[string]any{ + "username": strings.TrimSpace(r.FormValue("username")), + "subdomain": strings.ToLower(strings.TrimSpace(r.FormValue("subdomain"))), + "title": strings.TrimSpace(r.FormValue("title")), + "cfg": s.cfg, + } + pw := r.FormValue("password") + username, sub, title := f["username"].(string), f["subdomain"].(string), f["title"].(string) + if sub == "" { + sub = strings.ToLower(username) + f["subdomain"] = sub + } + if title == "" { + title = username + "'s blog" + } + var msg string + switch { + case !usernameRe.MatchString(username): + msg = "Username: 2-40 letters, digits, dots, dashes or underscores." + case len(pw) < 8: + msg = "Password must be at least 8 characters." + case !subdomainRe.MatchString(sub) || reservedSubdomains[sub]: + msg = "Subdomain: lowercase letters, digits and single dashes; not a reserved name." + } + if msg != "" { + f["error"] = msg + s.renderStatus(w, r, http.StatusBadRequest, "admin/new_user.html", f) + return + } + hash, err := auth.HashPassword(pw) + if err != nil { + s.serverError(w, err) + return + } + _, blog, err := s.st.CreateBlogger(r.Context(), username, hash, sub, title) + if err != nil { + if errors.Is(err, store.ErrConflict) { + f["error"] = "Username or subdomain already taken." + s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f) + return + } + s.serverError(w, err) + return + } + redirectOK(w, r, "/admin/", "Created "+username+" with blog "+blog.Subdomain+".") +} + +func (s *Server) adminTargetUser(w http.ResponseWriter, r *http.Request) *store.User { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + u, err := s.st.UserByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + } else { + s.serverError(w, err) + } + return nil + } + if u.ID == currentUser(r).ID { + s.plainError(w, http.StatusBadRequest, "You cannot do that to your own account here; use Account > Password.") + return nil + } + return u +} + +func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) { + u := s.adminTargetUser(w, r) + if u == nil { + return + } + pw := r.FormValue("password") + if len(pw) < 8 { + s.plainError(w, http.StatusBadRequest, "Password must be at least 8 characters.") + return + } + hash, err := auth.HashPassword(pw) + if err != nil { + s.serverError(w, err) + return + } + if err := s.st.SetPassword(r.Context(), u.ID, hash); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/admin/", "Password reset for "+u.Username+".") +} + +func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := s.adminTargetUser(w, r) + if u == nil { + return + } + if err := s.st.SetUserDisabled(r.Context(), u.ID, disabled); err != nil { + s.serverError(w, err) + return + } + verb := "enabled" + if disabled { + verb = "disabled" + } + redirectOK(w, r, "/admin/", u.Username+" "+verb+".") + } +} + +func (s *Server) handleAdminDeleteUserConfirm(w http.ResponseWriter, r *http.Request) { + u := s.adminTargetUser(w, r) + if u == nil { + return + } + s.render(w, r, "admin/delete_user.html", map[string]any{"target": u}) +} + +func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { + u := s.adminTargetUser(w, r) + if u == nil { + return + } + if err := s.st.DeleteUser(r.Context(), u.ID); err != nil { // cascades to blog, pages, posts, images + s.serverError(w, err) + return + } + redirectOK(w, r, "/admin/", "Deleted "+u.Username+" and their blog.") +} diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go new file mode 100644 index 0000000..4554d94 --- /dev/null +++ b/internal/web/handlers_auth.go @@ -0,0 +1,131 @@ +package web + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/store" +) + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + s.render(w, r, "auth/login.html", map[string]any{"next": safeNext(r.URL.Query().Get("next"))}) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + next := safeNext(r.FormValue("next")) + fail := func() { + s.renderStatus(w, r, http.StatusUnauthorized, "auth/login.html", + map[string]any{"error": "Wrong username or password.", "username": username, "next": next}) + } + u, err := s.st.UserByUsername(r.Context(), username) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + s.serverError(w, err) + return + } + auth.CheckPassword("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinval", password) // constant-ish time + fail() + return + } + if u.Disabled || !auth.CheckPassword(u.PasswordHash, password) { + fail() + return + } + tok, err := auth.IssueToken(s.cfg.JWTSecret, u.ID, u.TokenVersion, time.Now()) + if err != nil { + s.serverError(w, err) + return + } + auth.SetSessionCookie(w, tok) + if next == "" { + next = "/dashboard" + } + http.Redirect(w, r, next, http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + auth.ClearSessionCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u.IsSuperadmin() { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + blog, err := s.st.BlogByOwner(r.Context(), u.ID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.plainError(w, http.StatusNotFound, "You have no blog yet. Ask the administrator to create one.") + return + } + s.serverError(w, err) + return + } + http.Redirect(w, r, "/b/"+blog.Subdomain+"/", http.StatusSeeOther) +} + +func (s *Server) handlePasswordForm(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "dashboard/password.html", nil) +} + +func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + cur, pw, pw2 := r.FormValue("current"), r.FormValue("password"), r.FormValue("password2") + var msg string + switch { + case !auth.CheckPassword(u.PasswordHash, cur): + msg = "Current password is wrong." + case len(pw) < 8: + msg = "New password must be at least 8 characters." + case pw != pw2: + msg = "New passwords do not match." + } + if msg != "" { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/password.html", map[string]any{"error": msg}) + return + } + hash, err := auth.HashPassword(pw) + if err != nil { + s.serverError(w, err) + return + } + if err := s.st.SetPassword(r.Context(), u.ID, hash); err != nil { + s.serverError(w, err) + return + } + // token_version changed, so re-issue the session instead of logging the user out + tok, err := auth.IssueToken(s.cfg.JWTSecret, u.ID, u.TokenVersion+1, time.Now()) + if err != nil { + s.serverError(w, err) + return + } + auth.SetSessionCookie(w, tok) + redirectOK(w, r, "/dashboard", "Password changed.") +} + +// safeNext only allows local paths as post-login redirect targets. +func safeNext(n string) string { + if strings.HasPrefix(n, "/") && !strings.HasPrefix(n, "//") { + return n + } + return "" +} diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go new file mode 100644 index 0000000..b768c4d --- /dev/null +++ b/internal/web/handlers_blog.go @@ -0,0 +1,150 @@ +package web + +import ( + "encoding/xml" + "errors" + "net/http" + "strconv" + "time" + + "github.com/gramanas/blogspace/internal/store" +) + +const postsPerPage = 10 + +// blogView gathers what every public page needs: theme css, nav, the blog. +func (s *Server) blogView(r *http.Request) (map[string]any, error) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + return nil, err + } + theme := ParseTheme(blog.ThemeJSON) + var nav []store.Page + for _, p := range pages { + if p.ShowInNav && (theme.NavShowHome || !p.IsHome) { + nav = append(nav, p) + } + } + return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav}, nil +} + +func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { + page, err := s.st.HomePage(r.Context(), currentBlog(r).ID) + if err != nil { + s.blogNotFound(w, r) + return + } + s.renderPage(w, r, page) +} + +func (s *Server) handleBlogPage(w http.ResponseWriter, r *http.Request) { + page, err := s.st.PageBySlug(r.Context(), currentBlog(r).ID, r.PathValue("page")) + if err != nil { + s.blogNotFound(w, r) + return + } + if page.IsHome { // canonical URL for the home page is / + http.Redirect(w, r, "/", http.StatusMovedPermanently) + return + } + s.renderPage(w, r, page) +} + +func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.Page) { + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + n, _ := strconv.Atoi(r.URL.Query().Get("p")) + if n < 1 { + n = 1 + } + posts, total, err := s.st.PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage) + if err != nil { + s.serverError(w, err) + return + } + last := (total + postsPerPage - 1) / postsPerPage + v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last + v["base"] = "/" + page.Slug + if page.IsHome { + v["base"] = "" + } + s.render(w, r, "blog/page.html", v) +} + +func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + page, err := s.st.PageBySlug(r.Context(), blog.ID, r.PathValue("page")) + if err != nil { + s.blogNotFound(w, r) + return + } + post, err := s.st.PublishedPostBySlug(r.Context(), page.ID, r.PathValue("post")) + if err != nil { + s.blogNotFound(w, r) + return + } + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + v["page"], v["post"] = page, post + s.render(w, r, "blog/post.html", v) +} + +func (s *Server) blogNotFound(w http.ResponseWriter, r *http.Request) { + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + s.renderStatus(w, r, http.StatusNotFound, "blog/404.html", v) +} + +// ---- RSS --------------------------------------------------------------------- + +type rss struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Items []rssItem `xml:"item"` + } `xml:"channel"` +} + +type rssItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + GUID string `xml:"guid"` + PubDate string `xml:"pubDate"` + Desc string `xml:"description"` +} + +func (s *Server) handleBlogFeed(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + posts, err := s.st.RecentPublishedPosts(r.Context(), blog.ID, 30) + if err != nil { + s.serverError(w, err) + return + } + base := s.cfg.BlogURL(blog.Subdomain) + var f rss + f.Version = "2.0" + f.Channel.Title, f.Channel.Link, f.Channel.Description = blog.Title, base, blog.Tagline + for _, p := range posts { + link := base + "/" + p.PageSlug + "/" + p.Slug + f.Channel.Items = append(f.Channel.Items, rssItem{Title: p.Title, Link: link, GUID: link, + PubDate: p.CreatedAt.Format(time.RFC1123Z), Desc: p.BodyHTML}) + } + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + w.Write([]byte(xml.Header)) + if err := xml.NewEncoder(w).Encode(f); err != nil && !errors.Is(err, http.ErrHandlerTimeout) { + return + } +} diff --git a/internal/web/handlers_dashboard.go b/internal/web/handlers_dashboard.go new file mode 100644 index 0000000..f210891 --- /dev/null +++ b/internal/web/handlers_dashboard.go @@ -0,0 +1,43 @@ +package web + +import ( + "net/http" + "strings" +) + +func (s *Server) handleBlogOverview(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + posts, err := s.st.ListPosts(r.Context(), blog.ID, 0) + if err != nil { + s.serverError(w, err) + return + } + if len(posts) > 5 { + posts = posts[:5] + } + s.render(w, r, "dashboard/overview.html", map[string]any{"pages": pages, "posts": posts}) +} + +func (s *Server) handleSettingsForm(w http.ResponseWriter, r *http.Request) { + s.render(w, r, "dashboard/settings.html", nil) +} + +func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + title := strings.TrimSpace(r.FormValue("title")) + tagline := strings.TrimSpace(r.FormValue("tagline")) + if title == "" || len(title) > 120 || len(tagline) > 300 { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/settings.html", map[string]any{"error": "Title is required (max 120 chars); tagline max 300."}) + return + } + if err := s.st.UpdateBlogSettings(r.Context(), blog.ID, title, tagline); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/settings", "Saved. Refresh your blog to see it.") +} diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go new file mode 100644 index 0000000..601ba34 --- /dev/null +++ b/internal/web/handlers_design.go @@ -0,0 +1,155 @@ +package web + +import ( + "bytes" + "errors" + "io" + "mime/multipart" + "net/http" + "path/filepath" + "strconv" + + "github.com/google/uuid" + "github.com/gramanas/blogspace/internal/store" +) + +var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true} + +func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + images, err := s.st.ListImages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/design.html", map[string]any{"theme": ParseTheme(blog.ThemeJSON), "images": images}) +} + +func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { + s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.") + return + } + theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form) + // Optional direct uploads from the design form. + for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage} { + img, err := s.readUpload(r, field) + if err != nil { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "error": err.Error()}) + return + } + if img != nil { + *dst = img.ID.String() + } + } + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design saved. Refresh your blog to see it.") +} + +// readUpload stores the file from a multipart field, returning nil if the field is empty. +func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error) { + if r.MultipartForm == nil { + return nil, nil + } + fhs := r.MultipartForm.File[field] + if len(fhs) == 0 { + return nil, nil + } + return s.storeUpload(r, fhs[0]) +} + +func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.Image, error) { + if fh.Size > s.cfg.MaxUploadBytes { + return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")") + } + f, err := fh.Open() + if err != nil { + return nil, err + } + defer f.Close() + var buf bytes.Buffer + if _, err := io.CopyN(&buf, f, s.cfg.MaxUploadBytes+1); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if int64(buf.Len()) > s.cfg.MaxUploadBytes { + return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")") + } + ct := http.DetectContentType(buf.Bytes()) + if !allowedImageTypes[ct] { + return nil, errors.New("only PNG, JPEG, GIF and WebP images are accepted") + } + name := filepath.Base(fh.Filename) + if name == "" || name == "." || len(name) > 120 { + name = "image" + } + return s.st.CreateImage(r.Context(), currentBlog(r).ID, name, ct, buf.Bytes()) +} + +func kbString(n int64) string { + if n >= 1<<20 { + return strconv.FormatInt(n>>20, 10) + " MB" + } + return strconv.FormatInt(n>>10, 10) + " KB" +} + +// ---- image library --------------------------------------------------------- + +func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) { + images, err := s.st.ListImages(r.Context(), currentBlog(r).ID) + if err != nil { + s.serverError(w, err) + return + } + s.render(w, r, "dashboard/images.html", map[string]any{"images": images}) +} + +func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + if err := r.ParseMultipartForm(1 << 20); err != nil { + s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.") + return + } + img, err := s.readUpload(r, "file") + if err != nil { + s.plainError(w, http.StatusBadRequest, err.Error()) + return + } + if img == nil { + s.plainError(w, http.StatusBadRequest, "Choose a file first.") + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Uploaded "+img.Filename+".") +} + +func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.st.DeleteImage(r.Context(), blog.ID, id); err != nil { + s.serverError(w, err) + return + } + // Drop dangling references from the theme. + theme := ParseTheme(blog.ThemeJSON) + changed := false + if theme.BgImage == id.String() { + theme.BgImage, changed = "", true + } + if theme.HeaderImage == id.String() { + theme.HeaderImage, changed = "", true + } + if changed { + if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil { + s.serverError(w, err) + return + } + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Image deleted.") +} diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go new file mode 100644 index 0000000..e651135 --- /dev/null +++ b/internal/web/handlers_media.go @@ -0,0 +1,38 @@ +package web + +import ( + "bytes" + "errors" + "net/http" + + "github.com/google/uuid" + "github.com/gramanas/blogspace/internal/store" +) + +// handleMedia serves an uploaded image. Ids are immutable, so clients may cache forever. +func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + http.NotFound(w, r) + return + } + etag := `"` + id.String() + `"` + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + img, err := s.st.ImageData(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + s.serverError(w, err) + return + } + w.Header().Set("Content-Type", img.ContentType) + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("ETag", etag) + w.Header().Set("X-Content-Type-Options", "nosniff") + http.ServeContent(w, r, img.Filename, img.CreatedAt, bytes.NewReader(img.Data)) +} diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go new file mode 100644 index 0000000..1b79280 --- /dev/null +++ b/internal/web/handlers_pages.go @@ -0,0 +1,161 @@ +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. +var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true} + +func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { + pages, err := s.st.ListPages(r.Context(), currentBlog(r).ID) + 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 := s.st.PageByID(r.Context(), currentBlog(r).ID, 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{BlogID: blog.ID} + 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 = r.FormValue("intro") + p.ShowInNav = r.FormValue("show_in_nav") == "on" + autoSlug := p.Slug == "" + if autoSlug { + p.Slug = slug.Make(p.Title) + } + var msg string + switch { + case p.Title == "" || len(p.Title) > 120: + msg = "Title is required (max 120 characters)." + case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]: + msg = "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.IntroHTML = markdown.Render(p.IntroMD) + var err error + 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 = s.st.CreatePage(r.Context(), p); err == nil { + p = created + } + } else { + err = s.st.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": "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", "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": "the page \"" + p.Title + "\" and all " + strconv.Itoa(p.PostCount) + " of its posts", + "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, "The home page cannot be deleted. Make another page the home page first.") + return + } + if err := s.st.DeletePage(r.Context(), p.BlogID, p.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "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 := s.st.MovePage(r.Context(), p.BlogID, 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 := s.st.SetHomePage(r.Context(), p.BlogID, p.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "\""+p.Title+"\" is now the home page.") +} diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go new file mode 100644 index 0000000..8678985 --- /dev/null +++ b/internal/web/handlers_posts.go @@ -0,0 +1,158 @@ +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" +) + +func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pageID, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + posts, err := s.st.ListPosts(r.Context(), blog.ID, 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 := s.st.PostByID(r.Context(), currentBlog(r).ID, 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) handlePostForm(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + p := &store.Post{Published: true} + 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 := s.st.HomePage(r.Context(), blog.ID); err == nil { + p.PageID = hp.ID + } + s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages}) +} + +func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) + pages, err := s.st.ListPages(r.Context(), blog.ID) + if err != nil { + s.serverError(w, err) + return + } + p := &store.Post{} + 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.Published = r.FormValue("published") == "on" + p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) + autoSlug := p.Slug == "" + if autoSlug { + p.Slug = slug.Make(p.Title) + } + fail := func(status int, msg string) { + s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg}) + } + pageOK := false + for _, pg := range pages { + if pg.ID == p.PageID { + pageOK = true + } + } + switch { + case p.Title == "" || len(p.Title) > 200: + fail(http.StatusBadRequest, "Title is required (max 200 characters).") + return + case !slug.Valid(p.Slug): + fail(http.StatusBadRequest, "Slug may only contain lowercase letters, digits and dashes.") + return + case !pageOK: + fail(http.StatusBadRequest, "Pick a page for this post.") + return + case len(p.BodyMD) > 200_000: + fail(http.StatusBadRequest, "Post is too long (200 KB max).") + return + } + p.BodyHTML = markdown.Render(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 = s.st.CreatePost(r.Context(), p); err == nil { + p = created + } + } else { + err = s.st.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, "A post with that slug already exists on this page; choose another slug.") + return + } + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.") +} + +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": "the post \"" + 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 := s.st.DeletePost(r.Context(), currentBlog(r).ID, p.ID); err != nil { + s.serverError(w, err) + return + } + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", "Post deleted.") +} diff --git a/internal/web/routes.go b/internal/web/routes.go new file mode 100644 index 0000000..c889aa1 --- /dev/null +++ b/internal/web/routes.go @@ -0,0 +1,81 @@ +package web + +import ( + "net/http" + "net/url" +) + +func urlQuery(s string) string { return url.QueryEscape(s) } + +func (s *Server) rootRoutes() http.Handler { + m := http.NewServeMux() + m.HandleFunc("GET /{$}", s.handleIndex) + m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) + m.HandleFunc("GET /login", s.handleLoginForm) + m.HandleFunc("POST /login", s.handleLogin) + m.HandleFunc("POST /logout", s.handleLogout) + m.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard)) + m.HandleFunc("GET /account/password", s.requireAuth(s.handlePasswordForm)) + m.HandleFunc("POST /account/password", s.requireAuth(s.handlePassword)) + + // blog management (owner or superadmin) + m.HandleFunc("GET /b/{sub}/{$}", s.withBlog(s.handleBlogOverview)) + m.HandleFunc("GET /b/{sub}/settings", s.withBlog(s.handleSettingsForm)) + m.HandleFunc("POST /b/{sub}/settings", s.withBlog(s.handleSettings)) + m.HandleFunc("GET /b/{sub}/pages", s.withBlog(s.handlePages)) + m.HandleFunc("GET /b/{sub}/pages/new", s.withBlog(s.handlePageForm)) + m.HandleFunc("POST /b/{sub}/pages/new", s.withBlog(s.handlePageSave)) + m.HandleFunc("GET /b/{sub}/pages/{id}/edit", s.withBlog(s.handlePageForm)) + m.HandleFunc("POST /b/{sub}/pages/{id}/edit", s.withBlog(s.handlePageSave)) + m.HandleFunc("GET /b/{sub}/pages/{id}/delete", s.withBlog(s.handlePageDeleteConfirm)) + m.HandleFunc("POST /b/{sub}/pages/{id}/delete", s.withBlog(s.handlePageDelete)) + m.HandleFunc("POST /b/{sub}/pages/{id}/move", s.withBlog(s.handlePageMove)) + m.HandleFunc("POST /b/{sub}/pages/{id}/home", s.withBlog(s.handlePageHome)) + m.HandleFunc("GET /b/{sub}/posts", s.withBlog(s.handlePosts)) + m.HandleFunc("GET /b/{sub}/posts/new", s.withBlog(s.handlePostForm)) + m.HandleFunc("POST /b/{sub}/posts/new", s.withBlog(s.handlePostSave)) + m.HandleFunc("GET /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostForm)) + m.HandleFunc("POST /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostSave)) + m.HandleFunc("GET /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDeleteConfirm)) + m.HandleFunc("POST /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDelete)) + m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm)) + m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign)) + m.HandleFunc("GET /b/{sub}/images", s.withBlog(s.handleImages)) + m.HandleFunc("POST /b/{sub}/images/upload", s.withBlog(s.handleImageUpload)) + m.HandleFunc("POST /b/{sub}/images/{id}/delete", s.withBlog(s.handleImageDelete)) + + // superadmin + m.HandleFunc("GET /admin/{$}", s.requireAdmin(s.handleAdmin)) + m.HandleFunc("GET /admin/users/new", s.requireAdmin(s.handleAdminNewUserForm)) + m.HandleFunc("POST /admin/users/new", s.requireAdmin(s.handleAdminNewUser)) + m.HandleFunc("POST /admin/users/{id}/reset-password", s.requireAdmin(s.handleAdminResetPassword)) + m.HandleFunc("POST /admin/users/{id}/disable", s.requireAdmin(s.handleAdminSetDisabled(true))) + m.HandleFunc("POST /admin/users/{id}/enable", s.requireAdmin(s.handleAdminSetDisabled(false))) + m.HandleFunc("GET /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUserConfirm)) + m.HandleFunc("POST /admin/users/{id}/delete", s.requireAdmin(s.handleAdminDeleteUser)) + + m.HandleFunc("GET /media/{id}", s.handleMedia) + m.Handle("GET /static/{file}", s.staticHandler()) + return s.session(m) +} + +func (s *Server) blogRoutes() http.Handler { + m := http.NewServeMux() + m.HandleFunc("GET /{$}", s.handleBlogHome) + m.HandleFunc("GET /feed.xml", s.handleBlogFeed) + m.HandleFunc("GET /media/{id}", s.handleMedia) + m.Handle("GET /static/{file}", s.staticHandler()) + m.HandleFunc("GET /{page}", s.handleBlogPage) + m.HandleFunc("GET /{page}/{post}", s.handleBlogPost) + return m +} + +func (s *Server) staticHandler() http.Handler { + fs := http.StripPrefix("/static/", http.FileServer(http.FS(s.tpl.AssetsFS()))) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.cfg.Dev { + w.Header().Set("Cache-Control", "public, max-age=3600") + } + fs.ServeHTTP(w, r) + }) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..e8a5e6d --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,251 @@ +// Package web is the HTTP layer: host routing, handlers and templates. +package web + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "strings" + + "github.com/gramanas/blogspace/internal/auth" + "github.com/gramanas/blogspace/internal/config" + "github.com/gramanas/blogspace/internal/store" +) + +// Subdomains that can never be blogs (kept free for infrastructure). +var reservedSubdomains = map[string]bool{"www": true, "admin": true, "api": true, "mail": true, "static": true, "media": true, "ftp": true, "smtp": true} + +type Server struct { + cfg *config.Config + st *store.Store + tpl *templates + root http.Handler + blog http.Handler +} + +func NewServer(cfg *config.Config, st *store.Store) *Server { + s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev, funcs)} + s.root = s.rootRoutes() + s.blog = s.blogRoutes() + return s +} + +// ServeHTTP dispatches on the Host header: the base domain is the management +// site, one label below it is a blog, anything else is a 404. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + host := hostname(r.Host) + switch { + case host == s.cfg.BaseDomain, host == "www."+s.cfg.BaseDomain: + s.root.ServeHTTP(w, r) + case strings.HasSuffix(host, "."+s.cfg.BaseDomain): + sub := strings.TrimSuffix(host, "."+s.cfg.BaseDomain) + if strings.Contains(sub, ".") || reservedSubdomains[sub] { + http.NotFound(w, r) + return + } + blog, err := s.st.BlogBySubdomain(r.Context(), sub) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.plainError(w, http.StatusNotFound, "No blog here (yet).") + return + } + s.serverError(w, err) + return + } + s.blog.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog))) + default: + s.plainError(w, http.StatusNotFound, fmt.Sprintf("Unknown host %q. Blogs live at .%s", host, s.cfg.BaseDomain)) + } +} + +func hostname(h string) string { + if host, _, err := net.SplitHostPort(h); err == nil { + h = host + } + return strings.ToLower(strings.TrimSuffix(h, ".")) +} + +// ---- context keys -------------------------------------------------------- + +type ctxKey int + +const ( + ctxUser ctxKey = iota + ctxBlog +) + +func currentUser(r *http.Request) *store.User { + u, _ := r.Context().Value(ctxUser).(*store.User) + return u +} + +func currentBlog(r *http.Request) *store.Blog { + b, _ := r.Context().Value(ctxBlog).(*store.Blog) + return b +} + +// ---- middleware ---------------------------------------------------------- + +// session loads the user from the JWT cookie (if any) into the context. +func (s *Server) session(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(auth.CookieName) + if err != nil || c.Value == "" { + next.ServeHTTP(w, r) + return + } + claims, err := auth.ParseToken(s.cfg.JWTSecret, c.Value) + if err != nil { + auth.ClearSessionCookie(w) + next.ServeHTTP(w, r) + return + } + u, err := s.st.UserByID(r.Context(), claims.UserID) + if err != nil || u.Disabled || u.TokenVersion != claims.TokenVersion { + auth.ClearSessionCookie(w) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u))) + }) +} + +// requireAuth redirects anonymous users to the login page. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if u == nil { + http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther) + return + } + if r.Method == http.MethodPost { + // Cap the request body before any form parsing (uploads included). + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+1<<20) + if err := parseForm(r); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + s.plainError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20)) + return + } + s.plainError(w, http.StatusBadRequest, "Could not read the form.") + return + } + if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { + s.plainError(w, http.StatusForbidden, "Form expired or invalid. Go back, reload the page and try again.") + return + } + } + next(w, r) + } +} + +// parseForm parses urlencoded or multipart bodies, surfacing size errors. +func parseForm(r *http.Request) error { + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + return r.ParseMultipartForm(1 << 20) + } + return r.ParseForm() +} + +func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + if !currentUser(r).IsSuperadmin() { + s.plainError(w, http.StatusForbidden, "Superadmin only.") + return + } + next(w, r) + }) +} + +// withBlog resolves /b/{sub}/... and enforces owner-or-superadmin. +func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + blog, err := s.st.BlogBySubdomain(r.Context(), r.PathValue("sub")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + s.serverError(w, err) + return + } + if blog.OwnerID != u.ID && !u.IsSuperadmin() { + s.plainError(w, http.StatusForbidden, "This is not your blog.") + return + } + next(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog))) + }) +} + +// ---- rendering helpers --------------------------------------------------- + +// view is the common data every template receives; page data goes in Data. +type view struct { + User *store.User + CSRF string + Flash string + Error string + Blog *store.Blog + BlogURL string + RootURL string + Path string + Data map[string]any +} + +func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { + s.renderStatus(w, r, http.StatusOK, name, data) +} + +func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) { + if data == nil { + data = map[string]any{} + } + v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Data: data} + if v.User != nil { + v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion) + } + if v.Blog != nil { + v.BlogURL = s.cfg.BlogURL(v.Blog.Subdomain) + } + v.Flash = r.URL.Query().Get("ok") + if e, ok := data["error"].(string); ok { + v.Error = e + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := s.tpl.render(w, name, v); err != nil { + log.Printf("render %s: %v", name, err) + fmt.Fprintf(w, "
template error: %v
", err) + } +} + +func (s *Server) serverError(w http.ResponseWriter, err error) { + log.Printf("error: %v", err) + s.plainError(w, http.StatusInternalServerError, "Something went wrong.") +} + +func (s *Server) plainError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + fmt.Fprintf(w, `%d

%d %s

%s

Home

`, + status, status, http.StatusText(status), htmlEscape(msg)) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return r.Replace(s) +} + +// redirectOK redirects with a flash message shown by the layout. +func redirectOK(w http.ResponseWriter, r *http.Request, to, msg string) { + sep := "?" + if strings.Contains(to, "?") { + sep = "&" + } + http.Redirect(w, r, to+sep+"ok="+urlQuery(msg), http.StatusSeeOther) +} diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css new file mode 100644 index 0000000..b09689b --- /dev/null +++ b/internal/web/static/blog.css @@ -0,0 +1,50 @@ +/* Public blog base styles — colours, fonts and widths come from the inline theme + + +{{if eq .Data.theme.NavPosition "top-bar"}}{{template "blognav" .}}{{end}} + +{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}} +
+ {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{end}} +
+ {{template "content" .}} +
+
+ + +{{end}} + +{{define "blognav"}}{{end}} diff --git a/internal/web/templates/layouts/dashboard.html b/internal/web/templates/layouts/dashboard.html new file mode 100644 index 0000000..6633c29 --- /dev/null +++ b/internal/web/templates/layouts/dashboard.html @@ -0,0 +1,17 @@ +{{define "layout"}} + + + + +{{block "title" .}}Blogspace{{end}} + + + +{{template "dashnav" .}} +
+{{if .Flash}}
{{.Flash}}
{{end}} +{{if .Error}}
{{.Error}}
{{end}} +{{template "content" .}} +
+ +{{end}} diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html new file mode 100644 index 0000000..13a04d0 --- /dev/null +++ b/internal/web/templates/partials/dashnav.html @@ -0,0 +1,27 @@ +{{define "dashnav"}}
+
+ Blogspace + {{if .User}} + {{.User.Username}}{{if .User.IsSuperadmin}} (superadmin){{end}} + + {{if .User.IsSuperadmin}}Admin{{end}} + Password +
+
+ {{end}} +
+
+{{if .Blog}} +
+
+ {{.Blog.Title}} + Overview + Posts + Pages + Design + Images + Settings + View blog ↗ +
+
+{{end}}{{end}} diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html new file mode 100644 index 0000000..687c6cd --- /dev/null +++ b/internal/web/templates/partials/imagepick.html @@ -0,0 +1,11 @@ +{{define "imagepick"}}
+ + + {{if .current}} + {{end}} +
{{end}} diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html new file mode 100644 index 0000000..878130e --- /dev/null +++ b/internal/web/templates/partials/mdhelp.html @@ -0,0 +1,9 @@ +{{define "mdhelp"}}
+

Formatting cheat-sheet

+ + + + +
# Heading, ## Smaller heading**bold**, *italic*
[link text](https://example.org)![description](/media/…) — copy the address from Images
- list item / 1. numbered> quote, `code`, --- for a line
+

Blank line = new paragraph. Press Enter once for a line break.

+
{{end}} diff --git a/internal/web/theme.go b/internal/web/theme.go new file mode 100644 index 0000000..40efc54 --- /dev/null +++ b/internal/web/theme.go @@ -0,0 +1,212 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/url" + "regexp" + "strings" + "text/template" + + "github.com/google/uuid" +) + +// Theme is the structured, form-editable look of a blog. Stored as jsonb. +type Theme struct { + BgColor string `json:"bg_color"` + BgImage string `json:"bg_image"` // image uuid or "" + BgMode string `json:"bg_mode"` // cover | tile | fixed + + ContentBg string `json:"content_bg"` + TextColor string `json:"text_color"` + LinkColor string `json:"link_color"` + ContentWidth string `json:"content_width"` // narrow | medium | wide + Font string `json:"font"` // sans | serif | mono + FontSize string `json:"font_size"` // small | normal | large + + HeaderShowTitle bool `json:"header_show_title"` + HeaderImage string `json:"header_image"` + HeaderAlign string `json:"header_align"` // left | center + HeaderBg string `json:"header_bg"` + HeaderText string `json:"header_text"` + + NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar + NavBg string `json:"nav_bg"` + NavText string `json:"nav_text"` + NavShowHome bool `json:"nav_show_home"` + + FooterText string `json:"footer_text"` + FooterBg string `json:"footer_bg"` + FooterColor string `json:"footer_color"` +} + +func DefaultTheme() Theme { + return Theme{ + BgColor: "#e9e6df", BgMode: "cover", + ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", + ContentWidth: "medium", Font: "sans", FontSize: "normal", + HeaderShowTitle: true, HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", + NavPosition: "below-header", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true, + FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", + } +} + +// ParseTheme decodes stored JSON on top of the defaults so new fields get sane values. +func ParseTheme(raw json.RawMessage) Theme { + t := DefaultTheme() + if len(raw) > 0 { + _ = json.Unmarshal(raw, &t) + } + t.normalize() + return t +} + +var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +func pick(v string, allowed ...string) string { + for _, a := range allowed { + if v == a { + return v + } + } + return allowed[0] +} + +func color(v, def string) string { + v = strings.TrimSpace(v) + if hexColor.MatchString(v) { + return strings.ToLower(v) + } + return def +} + +func imageID(v string) string { + if _, err := uuid.Parse(v); err != nil { + return "" + } + return v +} + +// normalize clamps every field to an allowed value so the CSS template can trust them. +func (t *Theme) normalize() { + d := DefaultTheme() + t.BgColor = color(t.BgColor, d.BgColor) + t.BgImage = imageID(t.BgImage) + t.BgMode = pick(t.BgMode, "cover", "tile", "fixed") + t.ContentBg = color(t.ContentBg, d.ContentBg) + t.TextColor = color(t.TextColor, d.TextColor) + t.LinkColor = color(t.LinkColor, d.LinkColor) + t.ContentWidth = pick(t.ContentWidth, "medium", "narrow", "wide") + t.Font = pick(t.Font, "sans", "serif", "mono") + t.FontSize = pick(t.FontSize, "normal", "small", "large") + t.HeaderImage = imageID(t.HeaderImage) + t.HeaderAlign = pick(t.HeaderAlign, "left", "center") + t.HeaderBg = color(t.HeaderBg, d.HeaderBg) + t.HeaderText = color(t.HeaderText, d.HeaderText) + t.NavPosition = pick(t.NavPosition, "below-header", "top-bar", "left-sidebar") + t.NavBg = color(t.NavBg, d.NavBg) + t.NavText = color(t.NavText, d.NavText) + t.FooterBg = color(t.FooterBg, d.FooterBg) + t.FooterColor = color(t.FooterColor, d.FooterColor) + if len(t.FooterText) > 2000 { + t.FooterText = t.FooterText[:2000] + } +} + +// ThemeFromForm reads the design form on top of the current theme. +func ThemeFromForm(cur Theme, f url.Values) Theme { + t := cur + get := func(k string) string { return strings.TrimSpace(f.Get(k)) } + t.BgColor = get("bg_color") + t.BgMode = get("bg_mode") + t.ContentBg = get("content_bg") + t.TextColor = get("text_color") + t.LinkColor = get("link_color") + t.ContentWidth = get("content_width") + t.Font = get("font") + t.FontSize = get("font_size") + t.HeaderShowTitle = f.Get("header_show_title") == "on" + t.HeaderAlign = get("header_align") + t.HeaderBg = get("header_bg") + t.HeaderText = get("header_text") + t.NavPosition = get("nav_position") + t.NavBg = get("nav_bg") + t.NavText = get("nav_text") + t.NavShowHome = f.Get("nav_show_home") == "on" + t.FooterText = get("footer_text") + t.FooterBg = get("footer_bg") + t.FooterColor = get("footer_color") + if f.Get("bg_image_remove") == "on" { + t.BgImage = "" + } else if v := get("bg_image"); v != "" { + t.BgImage = v + } + if f.Get("header_image_remove") == "on" { + t.HeaderImage = "" + } else if v := get("header_image"); v != "" { + t.HeaderImage = v + } + t.normalize() + return t +} + +func (t Theme) JSON() json.RawMessage { + b, _ := json.Marshal(t) + return b +} + +// Helpers used by the CSS template. +func (t Theme) FontFamily() string { + switch t.Font { + case "serif": + return `Georgia, "Times New Roman", Times, serif` + case "mono": + return `"Courier New", Courier, monospace` + } + return `"Helvetica Neue", Helvetica, Arial, sans-serif` +} + +func (t Theme) FontSizePx() string { + switch t.FontSize { + case "small": + return "15px" + case "large": + return "19px" + } + return "17px" +} + +func (t Theme) MaxWidth() string { + switch t.ContentWidth { + case "narrow": + return "640px" + case "wide": + return "1100px" + } + return "840px" +} + +var themeCSS = template.Must(template.New("theme").Parse(` +body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55; +{{- if .BgImage}} background-image:url(/media/{{.BgImage}}); + {{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat; + {{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat; + {{- else}} background-repeat:repeat;{{end}} +{{- end}} } +a { color:{{.LinkColor}}; } +.wrap { max-width:{{.MaxWidth}}; margin:0 auto; } +.site-header { background:{{.HeaderBg}}; color:{{.HeaderText}}; text-align:{{.HeaderAlign}}; } +.site-header a { color:{{.HeaderText}}; } +.site-nav { background:{{.NavBg}}; } +.site-nav a { color:{{.NavText}}; } +.content { background:{{.ContentBg}}; } +.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; } +.site-footer a { color:{{.FooterColor}}; } +`)) + +// CSS renders the per-blog stylesheet; all values were normalized so it is safe to inline. +func (t Theme) CSS() string { + var b bytes.Buffer + _ = themeCSS.Execute(&b, t) + return b.String() +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..42e37a7 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,73 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gramanas/blogspace/internal/config" +) + +func TestHostname(t *testing.T) { + cases := map[string]string{"Example.com:8080": "example.com", "a.example.com": "a.example.com", "example.com.": "example.com", "[::1]:80": "::1"} + for in, want := range cases { + if got := hostname(in); got != want { + t.Errorf("hostname(%q) = %q, want %q", in, got, want) + } + } +} + +// Host routing that does not need the database: unknown hosts and reserved/nested subdomains 404 before any lookup. +func TestHostRoutingWithoutDB(t *testing.T) { + cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} + s := NewServer(cfg, nil) + for _, host := range []string{"evil.com", "a.b.example.com", "www.blog.example.com", "static.example.com", "notexample.com"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + req.Host = host + s.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("host %q: got %d, want 404", host, rec.Code) + } + } + // root domain (and www) reach the management mux: /login renders without DB access + for _, host := range []string{"example.com", "www.example.com", "example.com:8080"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/login", nil) + req.Host = host + s.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Log in") { + t.Errorf("host %q /login: got %d", host, rec.Code) + } + } +} + +func TestThemeNormalizeAndCSS(t *testing.T) { + th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","nav_position":"left-sidebar"}`)) + if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" { + t.Errorf("normalize: %+v", th) + } + f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"}} + th = ThemeFromForm(DefaultTheme(), f) + if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || !th.HeaderShowTitle || th.BgImage != "" { + t.Errorf("from form: %+v", th) + } + css := th.CSS() + if !strings.Contains(css, "background-color:#abcdef") || !strings.Contains(css, "max-width:1100px") || strings.Contains(css, "display:none") { + t.Errorf("css: %s", css) + } +} + +func TestAllTemplatesParse(t *testing.T) { + tpl := newTemplates(false, funcs) + for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", + "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", + "dashboard/password.html", "dashboard/confirm.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", + "blog/page.html", "blog/post.html", "blog/404.html"} { + if _, err := tpl.get(name); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} -- cgit v1.2.3