diff options
Diffstat (limited to 'internal/web')
37 files changed, 2345 insertions, 0 deletions
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 <name>.%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, "<pre>template error: %v</pre>", 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, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>%d</title></head><body style="font-family:sans-serif;max-width:40em;margin:4em auto;padding:0 1em"><h1>%d %s</h1><p>%s</p><p><a href="/">Home</a></p></body></html>`, + 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 <style>. */ +* { box-sizing: border-box; } +img { max-width: 100%; height: auto; } +.wrap { padding: 0 1em; } +.site-header { padding: 0; } +.header-image img { display: block; width: 100%; max-height: 320px; object-fit: cover; } +.header-text { padding: 1.4em 1em; } +.site-title { margin: 0; font-size: 2em; line-height: 1.2; } +.site-title a { text-decoration: none; } +.tagline { margin: 0.3em 0 0; opacity: 0.85; } +.site-nav { border-bottom: 1px solid rgba(0,0,0,0.1); } +.nav-inner { padding: 0.3em 1em; } +.site-nav a { display: inline-block; padding: 0.5em 0.8em; text-decoration: none; font-weight: bold; } +.site-nav a.active, .site-nav a:hover { text-decoration: underline; } +.body-wrap { padding: 1.5em 1em; overflow: hidden; } +.content { padding: 1.5em 2em; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.15); } +.page-title { margin-top: 0; } +.intro { padding-bottom: 1em; margin-bottom: 1.5em; border-bottom: 1px solid rgba(0,0,0,0.1); } +.post { margin-bottom: 2.5em; } +.post-title { margin: 0 0 0.1em; font-size: 1.5em; line-height: 1.25; } +.post-title a { text-decoration: none; } +.post-title a:hover { text-decoration: underline; } +.post-date { margin: 0 0 0.8em; font-size: 0.85em; opacity: 0.7; } +.post.single .post-title { font-size: 2em; } +.post-body blockquote { margin: 1em 0; padding: 0.2em 1em; border-left: 4px solid rgba(0,0,0,0.15); opacity: 0.9; } +.post-body pre { overflow-x: auto; padding: 0.8em 1em; background: rgba(0,0,0,0.06); border-radius: 4px; } +.post-body code { background: rgba(0,0,0,0.06); padding: 0 0.25em; border-radius: 3px; font-size: 0.92em; } +.post-body pre code { background: none; padding: 0; } +.post-body table { border-collapse: collapse; } +.post-body th, .post-body td { border: 1px solid rgba(0,0,0,0.15); padding: 0.3em 0.6em; } +.post-body hr { border: 0; border-top: 1px solid rgba(0,0,0,0.15); } +.pager { margin-top: 2em; padding-top: 1em; border-top: 1px solid rgba(0,0,0,0.1); text-align: center; } +.pager a, .pager span { margin: 0 0.8em; } +.muted { opacity: 0.7; } +.site-footer { margin-top: 1em; padding: 1.5em 0; text-align: center; } +.site-footer p { margin: 0.3em 0; } +.site-footer .small { font-size: 0.85em; opacity: 0.8; } + +/* left sidebar layout: float the menu beside the content */ +.nav-left-sidebar .body-wrap .site-nav { float: left; width: 180px; border: 0; border-radius: 6px; padding: 0.5em 0; } +.nav-left-sidebar .body-wrap .site-nav a { display: block; padding: 0.4em 1em; } +.nav-left-sidebar .body-wrap .site-nav .nav-inner { padding: 0; } +.nav-left-sidebar .body-wrap .content { margin-left: 200px; } +@media (max-width: 700px) { + .content { padding: 1em; } + .site-title { font-size: 1.5em; } + .nav-left-sidebar .body-wrap .site-nav { float: none; width: auto; margin-bottom: 1em; } + .nav-left-sidebar .body-wrap .site-nav a { display: inline-block; } + .nav-left-sidebar .body-wrap .content { margin-left: 0; } +} diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css new file mode 100644 index 0000000..48f9eff --- /dev/null +++ b/internal/web/static/dashboard.css @@ -0,0 +1,87 @@ +/* Blogspace dashboard — plain CSS, no JS required, works down to ~320px. */ +* { box-sizing: border-box; } +body { margin: 0; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #222; background: #f3f4f6; } +a { color: #1a5fb4; } +h1 { font-size: 1.6em; margin: 0.4em 0 0.6em; } +h2 { font-size: 1.2em; margin: 0 0 0.6em; } +h3 { font-size: 1.05em; margin: 0 0 0.4em; } +code { background: #eef; padding: 0 0.25em; border-radius: 3px; font-size: 0.95em; } +.muted { color: #666; } +.nowrap { white-space: nowrap; } +.narrow { max-width: 480px; } +.small { font-size: 0.9em; } + +.topbar { background: #1f2933; color: #fff; } +.topbar a { color: #fff; text-decoration: none; } +.topbar-inner { max-width: 1100px; margin: 0 auto; padding: 0.6em 1em; overflow: hidden; } +.brand { font-weight: bold; font-size: 1.1em; margin-right: 1em; } +.who { color: #b8c2cc; } +.topbar .links { float: right; } +.topbar .links a, .topbar .links form { margin-left: 1em; } +.blogbar { background: #fff; border-bottom: 1px solid #d9dde3; } +.blogbar .topbar-inner { padding: 0.5em 1em; } +.blogbar a { display: inline-block; padding: 0.3em 0.6em; text-decoration: none; color: #333; border-radius: 4px; } +.blogbar a:hover { background: #eef1f5; } +.blogbar .blogname { margin-right: 0.6em; } +.blogbar .view { float: right; color: #1a5fb4; font-weight: bold; } + +.main { max-width: 1100px; margin: 0 auto; padding: 1em; } +.card { background: #fff; border: 1px solid #d9dde3; border-radius: 6px; padding: 1em 1.2em; margin-bottom: 1em; } +.cols { overflow: hidden; } +.cols .card { float: left; width: 49%; margin-right: 2%; } +.cols .card + .card { margin-right: 0; } +.flash { padding: 0.7em 1em; border-radius: 6px; margin-bottom: 1em; } +.flash.ok { background: #e3f6e8; border: 1px solid #9ad3a8; color: #1c5a2a; } +.flash.err { background: #fdecec; border: 1px solid #f0a5a5; color: #7a1c1c; } +.tag { display: inline-block; font-size: 0.75em; background: #eef1f5; color: #555; padding: 0 0.5em; border-radius: 3px; margin-left: 0.4em; vertical-align: middle; } + +label { display: block; margin: 0.6em 0; } +input[type=text], input[type=password], input:not([type]), input[type=file], select, textarea { + width: 100%; max-width: 100%; padding: 0.45em 0.5em; border: 1px solid #b9c0c9; border-radius: 4px; font: inherit; background: #fff; } +input[type=color] { width: 4em; height: 2.2em; padding: 0.1em; border: 1px solid #b9c0c9; border-radius: 4px; background: #fff; } +textarea { font-family: "Courier New", Courier, monospace; font-size: 0.95em; line-height: 1.45; } +textarea.editor { min-height: 20em; } +label.check { display: block; } +label.check input { width: auto; margin-right: 0.4em; } +.row { overflow: hidden; } +.row label { float: left; margin-right: 1.5em; min-width: 12em; } +.row label:last-child { margin-right: 0; } +button, .btn { display: inline-block; font: inherit; padding: 0.45em 1em; border-radius: 4px; border: 1px solid #164b8f; background: #1a5fb4; color: #fff; cursor: pointer; text-decoration: none; } +button:hover, .btn:hover { background: #164b8f; } +.btn.secondary, button.secondary { background: #fff; color: #1a5fb4; } +.btn.small { font-size: 0.75em; vertical-align: middle; } +button.mini, .mini { font-size: 0.8em; padding: 0.15em 0.5em; background: #fff; color: #333; border-color: #b9c0c9; } +button.mini:hover { background: #eef1f5; } +button.danger { background: #b42318; border-color: #8a1a12; } +a.danger, button.mini.danger { color: #b42318; } +button.linkbtn { background: none; border: 0; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: none; } +form.inline, .inline { display: inline; } +details.inline summary { display: inline; cursor: pointer; } +.filter { margin: 0 0 1em; } +.filter select { width: auto; } + +table { border-collapse: collapse; width: 100%; } +th, td { text-align: left; padding: 0.45em 0.5em; border-bottom: 1px solid #e5e8ec; vertical-align: middle; } +th { font-size: 0.85em; text-transform: uppercase; color: #666; letter-spacing: 0.03em; } +tr.disabled td { color: #999; } +ul.plain { list-style: none; padding: 0; margin: 0; } +ul.plain li { padding: 0.2em 0; } +.help { background: #fbfbf4; } +.cheat td { border: 0; padding: 0.2em 1em 0.2em 0; } + +.gallery { overflow: hidden; } +.thumb { float: left; width: 200px; margin: 0 1em 1em 0; padding: 0.6em; text-align: center; } +.thumb img { max-width: 100%; max-height: 140px; } +.thumb .meta { font-size: 0.85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.thumb .copy { font-size: 0.8em; margin: 0.4em 0; font-family: "Courier New", monospace; } +.imagepick { margin-top: 0.5em; padding-top: 0.5em; border-top: 1px dashed #d9dde3; } +.imagepick img.preview { display: block; max-width: 240px; max-height: 120px; margin-top: 0.4em; border: 1px solid #d9dde3; } +.imagepick select { width: auto; max-width: 100%; } + +@media (max-width: 700px) { + .cols .card, .row label, .thumb { float: none; width: auto; margin-right: 0; } + .topbar .links, .blogbar .view { float: none; display: block; margin-top: 0.3em; } + .topbar .links a, .topbar .links form { margin-left: 0; margin-right: 1em; } + table { font-size: 0.9em; } + th, td { padding: 0.35em 0.3em; } +} diff --git a/internal/web/templates.go b/internal/web/templates.go new file mode 100644 index 0000000..f322ccc --- /dev/null +++ b/internal/web/templates.go @@ -0,0 +1,117 @@ +package web + +import ( + "embed" + "fmt" + "html/template" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +//go:embed templates static +var assets embed.FS + +// templates compiles each page together with its layout. In dev mode it +// re-reads from disk on every render so edits show up without a restart. +type templates struct { + dev bool + fs fs.FS + funcs template.FuncMap + mu sync.Mutex + cache map[string]*template.Template +} + +func newTemplates(dev bool, funcs template.FuncMap) *templates { + t := &templates{dev: dev, funcs: funcs, cache: map[string]*template.Template{}} + t.fs = assets + if dev { + // locate the package directory so `go run` from anywhere still finds the files + _, file, _, _ := runtime.Caller(0) + dir := filepath.Dir(file) + if _, err := os.Stat(filepath.Join(dir, "templates")); err == nil { + t.fs = os.DirFS(dir) + } + } + return t +} + +// AssetsFS returns the static files (embedded or on disk in dev mode). +func (t *templates) AssetsFS() fs.FS { + sub, _ := fs.Sub(t.fs, "static") + return sub +} + +func layoutFor(name string) string { + if strings.HasPrefix(name, "blog/") { + return "layouts/blog.html" + } + return "layouts/dashboard.html" +} + +func (t *templates) get(name string) (*template.Template, error) { + if !t.dev { + t.mu.Lock() + if tpl, ok := t.cache[name]; ok { + t.mu.Unlock() + return tpl, nil + } + t.mu.Unlock() + } + tpl, err := template.New("").Funcs(t.funcs).ParseFS(t.fs, + "templates/"+layoutFor(name), "templates/partials/*.html", "templates/"+name) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", name, err) + } + if !t.dev { + t.mu.Lock() + t.cache[name] = tpl + t.mu.Unlock() + } + return tpl, nil +} + +func (t *templates) render(w io.Writer, name string, data any) error { + tpl, err := t.get(name) + if err != nil { + return err + } + return tpl.ExecuteTemplate(w, "layout", data) +} + +var funcs = template.FuncMap{ + "date": func(t time.Time) string { return t.Format("2 January 2006") }, + "rfc": func(t time.Time) string { return t.Format(time.RFC1123Z) }, + "html": func(s string) template.HTML { return template.HTML(s) }, + "css": func(s string) template.CSS { return template.CSS(s) }, + "kb": func(n int) string { return fmt.Sprintf("%.0f KB", float64(n)/1024) }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "deref": func(p *string) string { + if p == nil { + return "" + } + return *p + }, + "lower": strings.ToLower, + // dict builds a map for passing several values to a partial: {{template "x" (dict "a" 1 "b" 2)}} + "dict": func(kv ...any) (map[string]any, error) { + if len(kv)%2 != 0 { + return nil, fmt.Errorf("dict: odd number of arguments") + } + m := make(map[string]any, len(kv)/2) + for i := 0; i < len(kv); i += 2 { + k, ok := kv[i].(string) + if !ok { + return nil, fmt.Errorf("dict: key %v is not a string", kv[i]) + } + m[k] = kv[i+1] + } + return m, nil + }, +} diff --git a/internal/web/templates/admin/delete_user.html b/internal/web/templates/admin/delete_user.html new file mode 100644 index 0000000..34d3fb9 --- /dev/null +++ b/internal/web/templates/admin/delete_user.html @@ -0,0 +1,12 @@ +{{define "title"}}Delete user · Blogspace{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>Delete {{.Data.target.Username}}?</h1> + <p>This permanently deletes the user <strong>{{.Data.target.Username}}</strong>, their blog, and every page, post and image in it.</p> + <form method="post" action="/admin/users/{{.Data.target.ID}}/delete"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <button type="submit" class="danger">Yes, delete everything</button> + <a class="btn secondary" href="/admin/">Cancel</a> + </form> +</div> +{{end}} diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html new file mode 100644 index 0000000..c74d426 --- /dev/null +++ b/internal/web/templates/admin/index.html @@ -0,0 +1,28 @@ +{{define "title"}}Admin · Blogspace{{end}} +{{define "content"}} +<h1>Users & blogs <a class="btn small" href="/admin/users/new">+ New blogger</a></h1> +<div class="card"> +<table> + <tr><th>User</th><th>Role</th><th>Blog</th><th>Since</th><th>Actions</th></tr> + {{range .Data.users}}<tr{{if .Disabled}} class="disabled"{{end}}> + <td>{{.Username}}{{if .Disabled}} <span class="tag">disabled</span>{{end}}</td> + <td>{{.Role}}</td> + <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{deref .BlogTitle}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">{{deref .Subdomain}} ↗</a>{{else}}<span class="muted">—</span>{{end}}</td> + <td class="nowrap">{{date .CreatedAt}}</td> + <td class="nowrap"> + {{if ne .ID $.User.ID}} + <details class="inline"><summary class="mini">reset password</summary> + <form method="post" action="/admin/users/{{.ID}}/reset-password" class="inline"> + <input type="hidden" name="_csrf" value="{{$.CSRF}}"> + <input type="password" name="password" placeholder="new password" minlength="8" required size="14"> + <button class="mini">Set</button> + </form></details> + {{if .Disabled}}<form method="post" action="/admin/users/{{.ID}}/enable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">enable</button></form> + {{else}}<form method="post" action="/admin/users/{{.ID}}/disable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">disable</button></form>{{end}} + <a class="danger" href="/admin/users/{{.ID}}/delete">delete</a> + {{else}}<span class="muted">(you)</span>{{end}} + </td> + </tr>{{end}} +</table> +</div> +{{end}} diff --git a/internal/web/templates/admin/new_user.html b/internal/web/templates/admin/new_user.html new file mode 100644 index 0000000..87d1186 --- /dev/null +++ b/internal/web/templates/admin/new_user.html @@ -0,0 +1,15 @@ +{{define "title"}}New blogger · Blogspace{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>New blogger</h1> + <form method="post" action="/admin/users/new"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Username<br><input name="username" value="{{.Data.username}}" required pattern="[a-zA-Z0-9_.-]{2,40}" autofocus></label> + <label>Password <span class="muted">(min 8 characters; they can change it later)</span><br><input type="password" name="password" required minlength="8"></label> + <label>Subdomain <span class="muted">(defaults to the username)</span><br> + <span class="nowrap"><input name="subdomain" value="{{.Data.subdomain}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="63" size="14">.{{.Data.cfg.BaseDomain}}</span></label> + <label>Blog title<br><input name="title" value="{{.Data.title}}" maxlength="120"></label> + <button type="submit">Create</button> <a href="/admin/">Cancel</a> + </form> +</div> +{{end}} diff --git a/internal/web/templates/auth/login.html b/internal/web/templates/auth/login.html new file mode 100644 index 0000000..2229849 --- /dev/null +++ b/internal/web/templates/auth/login.html @@ -0,0 +1,12 @@ +{{define "title"}}Log in · Blogspace{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>Log in</h1> + <form method="post" action="/login"> + <input type="hidden" name="next" value="{{.Data.next}}"> + <label>Username<br><input name="username" value="{{.Data.username}}" required autofocus autocomplete="username"></label> + <label>Password<br><input type="password" name="password" required autocomplete="current-password"></label> + <button type="submit">Log in</button> + </form> +</div> +{{end}} diff --git a/internal/web/templates/blog/404.html b/internal/web/templates/blog/404.html new file mode 100644 index 0000000..b0aa6a9 --- /dev/null +++ b/internal/web/templates/blog/404.html @@ -0,0 +1,5 @@ +{{define "title"}}Not found · {{.Blog.Title}}{{end}} +{{define "content"}} +<h1>Not found</h1> +<p>There is nothing at this address. <a href="/">Back to the front page.</a></p> +{{end}} diff --git a/internal/web/templates/blog/page.html b/internal/web/templates/blog/page.html new file mode 100644 index 0000000..c1fe726 --- /dev/null +++ b/internal/web/templates/blog/page.html @@ -0,0 +1,19 @@ +{{define "title"}}{{if not .Data.page.IsHome}}{{.Data.page.Title}} · {{end}}{{.Blog.Title}}{{end}} +{{define "content"}} +{{if not .Data.page.IsHome}}<h1 class="page-title">{{.Data.page.Title}}</h1>{{end}} +{{if .Data.page.IntroHTML}}<div class="intro">{{html .Data.page.IntroHTML}}</div>{{end}} +{{range .Data.posts}} +<article class="post"> + <h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2> + <p class="post-date">{{date .CreatedAt}}</p> + <div class="post-body">{{html .BodyHTML}}</div> +</article> +{{else}}{{if not .Data.page.IntroHTML}}<p class="muted">Nothing here yet.</p>{{end}}{{end}} +{{if gt .Data.lastPage 1}} +<div class="pager"> + {{if gt .Data.pageNum 1}}<a href="{{.Data.base}}/?p={{sub .Data.pageNum 1}}">← Newer</a>{{end}} + <span>Page {{.Data.pageNum}} of {{.Data.lastPage}}</span> + {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.base}}/?p={{add .Data.pageNum 1}}">Older →</a>{{end}} +</div> +{{end}} +{{end}} diff --git a/internal/web/templates/blog/post.html b/internal/web/templates/blog/post.html new file mode 100644 index 0000000..83ce782 --- /dev/null +++ b/internal/web/templates/blog/post.html @@ -0,0 +1,8 @@ +{{define "title"}}{{.Data.post.Title}} · {{.Blog.Title}}{{end}} +{{define "content"}} +<article class="post single"> + <h1 class="post-title">{{.Data.post.Title}}</h1> + <p class="post-date">{{date .Data.post.CreatedAt}} · <a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p> + <div class="post-body">{{html .Data.post.BodyHTML}}</div> +</article> +{{end}} diff --git a/internal/web/templates/dashboard/confirm.html b/internal/web/templates/dashboard/confirm.html new file mode 100644 index 0000000..eefa1da --- /dev/null +++ b/internal/web/templates/dashboard/confirm.html @@ -0,0 +1,17 @@ +{{define "title"}}Confirm · Blogspace{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>Are you sure?</h1> + {{if .Data.isHome}} + <p>This is the home page; it cannot be deleted. Make another page the home page first.</p> + <p><a class="btn secondary" href="{{.Data.back}}">Back</a></p> + {{else}} + <p>You are about to permanently delete {{.Data.what}}. This cannot be undone.</p> + <form method="post" action="{{.Data.action}}"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <button type="submit" class="danger">Yes, delete</button> + <a class="btn secondary" href="{{.Data.back}}">Cancel</a> + </form> + {{end}} +</div> +{{end}} diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html new file mode 100644 index 0000000..27ce3c7 --- /dev/null +++ b/internal/web/templates/dashboard/design.html @@ -0,0 +1,87 @@ +{{define "title"}}Design · {{.Blog.Title}}{{end}} +{{define "content"}} +{{$t := .Data.theme}} +<h1>Design</h1> +<p class="muted">Save, then refresh <a href="{{.BlogURL}}" target="_blank">your blog ↗</a> to see the result. Colours are hex values like <code>#336699</code>.</p> +<form method="post" action="/b/{{.Blog.Subdomain}}/design" enctype="multipart/form-data"> +<input type="hidden" name="_csrf" value="{{.CSRF}}"> + +<div class="card"> + <h2>Background</h2> + <div class="row"> + <label>Colour<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label> + <label>Image style<br><select name="bg_mode"> + <option value="cover"{{if eq $t.BgMode "cover"}} selected{{end}}>Stretch to fill</option> + <option value="fixed"{{if eq $t.BgMode "fixed"}} selected{{end}}>Fill, fixed while scrolling</option> + <option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>Repeat (tile)</option> + </select></label> + </div> + {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "images" .Data.images "label" "Background image")}} +</div> + +<div class="card"> + <h2>Text & content area</h2> + <div class="row"> + <label>Content background<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label> + <label>Text colour<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label> + <label>Link colour<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label> + </div> + <div class="row"> + <label>Font<br><select name="font"> + <option value="sans"{{if eq $t.Font "sans"}} selected{{end}}>Sans-serif (Helvetica/Arial)</option> + <option value="serif"{{if eq $t.Font "serif"}} selected{{end}}>Serif (Georgia/Times)</option> + <option value="mono"{{if eq $t.Font "mono"}} selected{{end}}>Monospace (Courier)</option> + </select></label> + <label>Text size<br><select name="font_size"> + <option value="small"{{if eq $t.FontSize "small"}} selected{{end}}>Small</option> + <option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>Normal</option> + <option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>Large</option> + </select></label> + <label>Width<br><select name="content_width"> + <option value="narrow"{{if eq $t.ContentWidth "narrow"}} selected{{end}}>Narrow</option> + <option value="medium"{{if eq $t.ContentWidth "medium"}} selected{{end}}>Medium</option> + <option value="wide"{{if eq $t.ContentWidth "wide"}} selected{{end}}>Wide</option> + </select></label> + </div> +</div> + +<div class="card"> + <h2>Header</h2> + <div class="row"> + <label>Background<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label> + <label>Text colour<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label> + <label>Alignment<br><select name="header_align"> + <option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>Left</option> + <option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>Centred</option> + </select></label> + </div> + <label class="check"><input type="checkbox" name="header_show_title"{{if $t.HeaderShowTitle}} checked{{end}}> Show blog title and tagline in the header</label> + {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}} +</div> + +<div class="card"> + <h2>Menu</h2> + <div class="row"> + <label>Position<br><select name="nav_position"> + <option value="below-header"{{if eq $t.NavPosition "below-header"}} selected{{end}}>Below the header</option> + <option value="top-bar"{{if eq $t.NavPosition "top-bar"}} selected{{end}}>Bar at the very top</option> + <option value="left-sidebar"{{if eq $t.NavPosition "left-sidebar"}} selected{{end}}>Sidebar on the left</option> + </select></label> + <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label> + <label>Text colour<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label> + </div> + <label class="check"><input type="checkbox" name="nav_show_home"{{if $t.NavShowHome}} checked{{end}}> Include the home page in the menu</label> +</div> + +<div class="card"> + <h2>Footer</h2> + <label>Footer text<br><input name="footer_text" value="{{$t.FooterText}}" maxlength="2000" placeholder="© 2026 Me"></label> + <div class="row"> + <label>Background<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label> + <label>Text colour<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label> + </div> +</div> + +<p><button type="submit">Save design</button></p> +</form> +{{end}} diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html new file mode 100644 index 0000000..5383e73 --- /dev/null +++ b/internal/web/templates/dashboard/images.html @@ -0,0 +1,22 @@ +{{define "title"}}Images · {{.Blog.Title}}{{end}} +{{define "content"}} +<h1>Images</h1> +<div class="card"> + <form method="post" action="/b/{{.Blog.Subdomain}}/images/upload" enctype="multipart/form-data"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Upload an image <span class="muted">(PNG, JPEG, GIF or WebP)</span><br><input type="file" name="file" accept="image/*" required></label> + <button type="submit">Upload</button> + </form> +</div> +<p class="muted">To put an image in a post, copy its Markdown line into the post text.</p> +<div class="gallery"> +{{range .Data.images}} + <div class="card thumb"> + <a href="/media/{{.ID}}" target="_blank"><img src="/media/{{.ID}}" alt="{{.Filename}}"></a> + <div class="meta">{{.Filename}} <span class="muted">({{kb .Size}})</span></div> + <input class="copy" readonly value="" onclick="this.select()"> + <form method="post" action="/b/{{$.Blog.Subdomain}}/images/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">delete</button></form> + </div> +{{else}}<p class="muted">No images yet.</p>{{end}} +</div> +{{end}} diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html new file mode 100644 index 0000000..0ec72e6 --- /dev/null +++ b/internal/web/templates/dashboard/overview.html @@ -0,0 +1,35 @@ +{{define "title"}}{{.Blog.Title}} · Blogspace{{end}} +{{define "content"}} +<h1>{{.Blog.Title}}</h1> +<p class="muted">Your blog is live at <a href="{{.BlogURL}}" target="_blank">{{.BlogURL}}</a>. Every change you save here shows up there as soon as you refresh.</p> + +<div class="cols"> + <div class="card"> + <h2>Quick actions</h2> + <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p> + </div> + <div class="card"> + <h2>Pages</h2> + <ul class="plain"> + {{range .Data.pages}}<li><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.Title}}</a> <span class="muted">({{.PostCount}} posts{{if .IsHome}}, home{{end}}{{if not .ShowInNav}}, hidden from menu{{end}})</span></li>{{end}} + </ul> + </div> +</div> + +<div class="card"> + <h2>Latest posts</h2> + {{if .Data.posts}} + <table> + <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr> + {{range .Data.posts}}<tr> + <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">hidden</span>{{end}}</td> + <td>{{.PageTitle}}</td> + <td>{{date .CreatedAt}}</td> + <td><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view ↗</a></td> + </tr>{{end}} + </table> + <p><a href="/b/{{.Blog.Subdomain}}/posts">All posts</a></p> + {{else}}<p class="muted">No posts yet. <a href="/b/{{.Blog.Subdomain}}/posts/new">Write the first one.</a></p>{{end}} +</div> +{{end}} diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html new file mode 100644 index 0000000..df94c7e --- /dev/null +++ b/internal/web/templates/dashboard/page_form.html @@ -0,0 +1,21 @@ +{{define "title"}}{{if .Data.page.ID}}Edit page{{else}}New page{{end}} · {{.Blog.Title}}{{end}} +{{define "content"}} +<div class="card"> + <h1>{{if .Data.page.ID}}Edit page{{else}}New page{{end}}</h1> + <form method="post"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Title<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label> + <label>Address <span class="muted">(leave empty to make one from the title; e.g. <code>about</code> → {{.BlogURL}}/about)</span><br> + <input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> + <label>Intro text <span class="muted">(Markdown, shown above the posts; optional)</span><br> + <textarea name="intro" rows="8">{{.Data.page.IntroMD}}</textarea></label> + <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> Show in menu</label> + <p> + <button type="submit">Save</button> + {{if .Data.page.ID}}<a class="btn secondary" href="{{.BlogURL}}{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}" target="_blank">View page ↗</a>{{end}} + <a href="/b/{{.Blog.Subdomain}}/pages">Back to pages</a> + </p> + </form> +</div> +{{template "mdhelp"}} +{{end}} diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html new file mode 100644 index 0000000..6703e7f --- /dev/null +++ b/internal/web/templates/dashboard/pages.html @@ -0,0 +1,24 @@ +{{define "title"}}Pages · {{.Blog.Title}}{{end}} +{{define "content"}} +<h1>Pages <a class="btn small" href="/b/{{.Blog.Subdomain}}/pages/new">+ New page</a></h1> +<p class="muted">Pages appear in your blog's menu in this order. Each page holds its own list of posts.</p> +<div class="card"> +<table> + <tr><th>Menu order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr> + {{range .Data.pages}}<tr> + <td class="nowrap"> + <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> + <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + </td> + <td><a href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit">{{.Title}}</a> + {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag">hidden</span>{{end}}</td> + <td><a href="{{$.BlogURL}}{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}" target="_blank">{{if .IsHome}}/{{else}}/{{.Slug}}{{end}} ↗</a></td> + <td><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a></td> + <td class="nowrap"> + {{if not .IsHome}}<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/home" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">Make home</button></form> + <a class="danger" href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/delete">delete</a>{{end}} + </td> + </tr>{{end}} +</table> +</div> +{{end}} diff --git a/internal/web/templates/dashboard/password.html b/internal/web/templates/dashboard/password.html new file mode 100644 index 0000000..f333bd7 --- /dev/null +++ b/internal/web/templates/dashboard/password.html @@ -0,0 +1,13 @@ +{{define "title"}}Change password · Blogspace{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>Change password</h1> + <form method="post" action="/account/password"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Current password<br><input type="password" name="current" required autocomplete="current-password"></label> + <label>New password<br><input type="password" name="password" required minlength="8" autocomplete="new-password"></label> + <label>Repeat new password<br><input type="password" name="password2" required minlength="8" autocomplete="new-password"></label> + <button type="submit">Change password</button> + </form> +</div> +{{end}} diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html new file mode 100644 index 0000000..520b9e6 --- /dev/null +++ b/internal/web/templates/dashboard/post_form.html @@ -0,0 +1,26 @@ +{{define "title"}}{{if .Data.post.ID}}Edit post{{else}}New post{{end}} · {{.Blog.Title}}{{end}} +{{define "content"}} +<div class="card"> + <h1>{{if .Data.post.ID}}Edit post{{else}}New post{{end}}</h1> + <form method="post"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Title<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label> + <div class="row"> + <label>Page<br> + <select name="page_id">{{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.post.PageID}} selected{{end}}>{{.Title}}</option>{{end}}</select> + </label> + <label>Address <span class="muted">(optional, made from the title)</span><br> + <input name="slug" value="{{.Data.post.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> + </div> + <label>Content <span class="muted">(Markdown)</span><br> + <textarea name="body" rows="24" class="editor">{{.Data.post.BodyMD}}</textarea></label> + <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> Visible on the blog</label> + <p> + <button type="submit">Save</button> + {{if .Data.post.ID}}<a class="btn secondary" href="{{.BlogURL}}/{{.Data.post.PageSlug}}/{{.Data.post.Slug}}" target="_blank">View post ↗</a>{{end}} + <a href="/b/{{.Blog.Subdomain}}/posts">Back to posts</a> + </p> + </form> +</div> +{{template "mdhelp"}} +{{end}} diff --git a/internal/web/templates/dashboard/posts.html b/internal/web/templates/dashboard/posts.html new file mode 100644 index 0000000..24e86bb --- /dev/null +++ b/internal/web/templates/dashboard/posts.html @@ -0,0 +1,25 @@ +{{define "title"}}Posts · {{.Blog.Title}}{{end}} +{{define "content"}} +<h1>Posts <a class="btn small" href="/b/{{.Blog.Subdomain}}/posts/new{{if .Data.pageID}}?page={{.Data.pageID}}{{end}}">+ New post</a></h1> +<form method="get" class="filter"> + <label>Page: + <select name="page" onchange="this.form.submit()"> + <option value="0">All pages</option> + {{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.pageID}} selected{{end}}>{{.Title}}</option>{{end}} + </select></label> + <noscript><button type="submit">Filter</button></noscript> +</form> +<div class="card"> +{{if .Data.posts}} +<table> + <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr> + {{range .Data.posts}}<tr> + <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">hidden</span>{{end}}</td> + <td>{{.PageTitle}}</td> + <td class="nowrap">{{date .CreatedAt}}</td> + <td class="nowrap"><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view ↗</a> · <a class="danger" href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/delete">delete</a></td> + </tr>{{end}} +</table> +{{else}}<p class="muted">No posts here yet.</p>{{end}} +</div> +{{end}} diff --git a/internal/web/templates/dashboard/settings.html b/internal/web/templates/dashboard/settings.html new file mode 100644 index 0000000..24dffb4 --- /dev/null +++ b/internal/web/templates/dashboard/settings.html @@ -0,0 +1,13 @@ +{{define "title"}}Settings · {{.Blog.Title}}{{end}} +{{define "content"}} +<div class="card narrow"> + <h1>Blog settings</h1> + <form method="post" action="/b/{{.Blog.Subdomain}}/settings"> + <input type="hidden" name="_csrf" value="{{.CSRF}}"> + <label>Blog title<br><input name="title" value="{{.Blog.Title}}" required maxlength="120"></label> + <label>Tagline <span class="muted">(shown under the title)</span><br><input name="tagline" value="{{.Blog.Tagline}}" maxlength="300"></label> + <p class="muted">Address: <code>{{.BlogURL}}</code> — only the administrator can change this.</p> + <button type="submit">Save</button> + </form> +</div> +{{end}} diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html new file mode 100644 index 0000000..7e3128e --- /dev/null +++ b/internal/web/templates/layouts/blog.html @@ -0,0 +1,38 @@ +{{define "layout"}}<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{block "title" .}}{{.Blog.Title}}{{end}}</title> +<link rel="alternate" type="application/rss+xml" title="{{.Blog.Title}}" href="/feed.xml"> +<link rel="stylesheet" href="/static/blog.css"> +<style>{{css .Data.css}}</style> +</head> +<body class="nav-{{.Data.theme.NavPosition}}"> +{{if eq .Data.theme.NavPosition "top-bar"}}{{template "blognav" .}}{{end}} +<div class="site-header"> + {{if .Data.theme.HeaderImage}}<div class="header-image"><a href="/"><img src="/media/{{.Data.theme.HeaderImage}}" alt=""></a></div>{{end}} + {{if .Data.theme.HeaderShowTitle}}<div class="wrap header-text"> + <h1 class="site-title"><a href="/">{{.Blog.Title}}</a></h1> + {{if .Blog.Tagline}}<p class="tagline">{{.Blog.Tagline}}</p>{{end}} + </div>{{end}} +</div> +{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}} +<div class="wrap body-wrap"> + {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{end}} + <div class="content"> + {{template "content" .}} + </div> +</div> +<div class="site-footer"> + <div class="wrap footer-inner"> + {{if .Data.theme.FooterText}}<p>{{.Data.theme.FooterText}}</p>{{end}} + <p class="small"><a href="/feed.xml">RSS</a> · {{.Blog.Title}}</p> + </div> +</div> +</body> +</html>{{end}} + +{{define "blognav"}}<div class="site-nav"><div class="wrap nav-inner"> +{{range .Data.nav}}<a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}"{{if and $.Data.page (eq $.Data.page.ID .ID)}} class="active"{{end}}>{{.Title}}</a> +{{end}}</div></div>{{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"}}<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{block "title" .}}Blogspace{{end}}</title> +<link rel="stylesheet" href="/static/dashboard.css"> +</head> +<body> +{{template "dashnav" .}} +<main class="main"> +{{if .Flash}}<div class="flash ok">{{.Flash}}</div>{{end}} +{{if .Error}}<div class="flash err">{{.Error}}</div>{{end}} +{{template "content" .}} +</main> +</body> +</html>{{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"}}<div class="topbar"> + <div class="topbar-inner"> + <a class="brand" href="/">Blogspace</a> + {{if .User}} + <span class="who">{{.User.Username}}{{if .User.IsSuperadmin}} (superadmin){{end}}</span> + <span class="links"> + {{if .User.IsSuperadmin}}<a href="/admin/">Admin</a>{{end}} + <a href="/account/password">Password</a> + <form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit" class="linkbtn">Log out</button></form> + </span> + {{end}} + </div> +</div> +{{if .Blog}} +<div class="blogbar"> + <div class="topbar-inner"> + <strong class="blogname">{{.Blog.Title}}</strong> + <a href="/b/{{.Blog.Subdomain}}/">Overview</a> + <a href="/b/{{.Blog.Subdomain}}/posts">Posts</a> + <a href="/b/{{.Blog.Subdomain}}/pages">Pages</a> + <a href="/b/{{.Blog.Subdomain}}/design">Design</a> + <a href="/b/{{.Blog.Subdomain}}/images">Images</a> + <a href="/b/{{.Blog.Subdomain}}/settings">Settings</a> + <a class="view" href="{{.BlogURL}}" target="_blank">View blog ↗</a> + </div> +</div> +{{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"}}<div class="imagepick"> + <label>{{.label}}<br> + <select name="{{.name}}"> + <option value="">{{if .current}}(keep current){{else}}(none){{end}}</option> + {{range .images}}<option value="{{.ID}}"{{if eq .ID.String $.current}} selected{{end}}>{{.Filename}}</option>{{end}} + </select> + </label> + <label>…or upload a new one<br><input type="file" name="{{.name}}_file" accept="image/*"></label> + {{if .current}}<label class="check"><input type="checkbox" name="{{.name}}_remove"> Remove image</label> + <img class="preview" src="/media/{{.current}}" alt="">{{end}} +</div>{{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"}}<div class="card help"> + <h3>Formatting cheat-sheet</h3> + <table class="cheat"> + <tr><td><code># Heading</code>, <code>## Smaller heading</code></td><td><code>**bold**</code>, <code>*italic*</code></td></tr> + <tr><td><code>[link text](https://example.org)</code></td><td><code></code> — copy the address from <em>Images</em></td></tr> + <tr><td><code>- list item</code> / <code>1. numbered</code></td><td><code>> quote</code>, <code>`code`</code>, <code>---</code> for a line</td></tr> + </table> + <p class="muted">Blank line = new paragraph. Press Enter once for a line break.</p> +</div>{{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) + } + } +} |
