package server import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "html/template" "log/slog" "net/http" "net/http/httputil" "net/url" "os" "time" "github.com/go-chi/chi/v5" "github.com/writekitapp/writekit/internal/auth" "github.com/writekitapp/writekit/internal/build/assets" "github.com/writekitapp/writekit/internal/build/templates" "github.com/writekitapp/writekit/internal/config" "github.com/writekitapp/writekit/internal/markdown" "github.com/writekitapp/writekit/internal/tenant" "github.com/writekitapp/writekit/studio" ) func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain string) { var tenantID string var demoInfo DemoInfo tenantID, ok := s.tenantCache.Get(subdomain) if !ok { t, err := s.database.GetTenantBySubdomain(r.Context(), subdomain) if err != nil || t == nil { d, err := s.database.GetDemoBySubdomain(r.Context(), subdomain) if err != nil || d == nil { s.notFound(w, r) return } tenantID = d.ID demoInfo = DemoInfo{IsDemo: true, ExpiresAt: d.ExpiresAt} s.tenantPool.MarkAsDemo(tenantID) s.ensureDemoSeeded(tenantID) } else { tenantID = t.ID } s.tenantCache.Set(subdomain, tenantID) } else { d, _ := s.database.GetDemoBySubdomain(r.Context(), subdomain) if d != nil { demoInfo = DemoInfo{IsDemo: true, ExpiresAt: d.ExpiresAt} s.tenantPool.MarkAsDemo(tenantID) } } ctx := context.WithValue(r.Context(), tenantIDKey, tenantID) ctx = context.WithValue(ctx, demoInfoKey, demoInfo) r = r.WithContext(ctx) mux := chi.NewRouter() mux.Get("/", s.blogHome) mux.Get("/posts", s.blogList) mux.Get("/posts/{slug}", s.blogPost) mux.Handle("/static/*", http.StripPrefix("/static/", assets.Handler())) mux.Route("/api/studio", func(r chi.Router) { r.Use(demoAwareSessionMiddleware(s.database)) r.Use(s.ownerMiddleware) r.Mount("/", s.studioRoutes()) }) mux.Mount("/api/v1", s.publicAPIRoutes()) mux.Mount("/api/reader", s.readerRoutes()) mux.Get("/studio", s.serveStudio) mux.Get("/studio/*", s.serveStudio) mux.Get("/sitemap.xml", s.sitemap) mux.Get("/robots.txt", s.robots) mux.ServeHTTP(w, r) } func (s *Server) blogHome(w http.ResponseWriter, r *http.Request) { tenantID := r.Context().Value(tenantIDKey).(string) db, err := s.tenantPool.Get(tenantID) if err != nil { slog.Error("blogHome: get tenant pool", "error", err, "tenantID", tenantID) http.Error(w, "internal error", http.StatusInternalServerError) return } q := tenant.NewQueries(db) s.recordPageView(q, r, "/", "") if html, etag, err := q.GetPage(r.Context(), "/"); err == nil && html != nil { s.servePreRendered(w, r, html, etag, "public, s-maxage=31536000, max-age=0") return } posts, err := q.ListPosts(r.Context(), false) if err != nil { slog.Error("blogHome: list posts", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } settings, _ := q.GetSettings(r.Context()) siteName := getSettingOr(settings, "site_name", "My Blog") siteDesc := getSettingOr(settings, "site_description", "") baseURL := getBaseURL(r.Host) showBadge := true if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil { tierInfo := config.GetTierInfo(t.Premium) showBadge = tierInfo.Config.BadgeRequired } postSummaries := make([]templates.PostSummary, 0, len(posts)) for _, p := range posts { if len(postSummaries) >= 10 { break } postSummaries = append(postSummaries, templates.PostSummary{ Slug: p.Slug, Title: p.Title, Description: p.Description, Date: timeOrZero(p.PublishedAt), }) } data := templates.HomeData{ PageData: templates.PageData{ Title: siteName, Description: siteDesc, CanonicalURL: baseURL + "/", OGType: "website", SiteName: siteName, Year: time.Now().Year(), Settings: settingsToMap(settings), NoIndex: GetDemoInfo(r).IsDemo, ShowBadge: showBadge, }, Posts: postSummaries, HasMore: len(posts) > 10, } html, err := templates.RenderHome(data) if err != nil { slog.Error("blogHome: render template", "error", err) http.Error(w, "render error", http.StatusInternalServerError) return } s.servePreRendered(w, r, html, computeETag(html), "public, s-maxage=31536000, max-age=0") } func (s *Server) blogList(w http.ResponseWriter, r *http.Request) { tenantID := r.Context().Value(tenantIDKey).(string) db, err := s.tenantPool.Get(tenantID) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } q := tenant.NewQueries(db) s.recordPageView(q, r, "/posts", "") if html, etag, err := q.GetPage(r.Context(), "/posts"); err == nil && html != nil { s.servePreRendered(w, r, html, etag, "public, s-maxage=31536000, max-age=0") return } posts, err := q.ListPosts(r.Context(), false) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } settings, _ := q.GetSettings(r.Context()) siteName := getSettingOr(settings, "site_name", "My Blog") baseURL := getBaseURL(r.Host) showBadge := true if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil { tierInfo := config.GetTierInfo(t.Premium) showBadge = tierInfo.Config.BadgeRequired } postSummaries := make([]templates.PostSummary, len(posts)) for i, p := range posts { postSummaries[i] = templates.PostSummary{ Slug: p.Slug, Title: p.Title, Description: p.Description, Date: timeOrZero(p.PublishedAt), } } data := templates.BlogData{ PageData: templates.PageData{ Title: "Posts - " + siteName, Description: "All posts", CanonicalURL: baseURL + "/posts", OGType: "website", SiteName: siteName, Year: time.Now().Year(), Settings: settingsToMap(settings), NoIndex: GetDemoInfo(r).IsDemo, ShowBadge: showBadge, }, Posts: postSummaries, } html, err := templates.RenderBlog(data) if err != nil { http.Error(w, "render error", http.StatusInternalServerError) return } s.servePreRendered(w, r, html, computeETag(html), "public, s-maxage=31536000, max-age=0") } func (s *Server) blogPost(w http.ResponseWriter, r *http.Request) { tenantID := r.Context().Value(tenantIDKey).(string) slug := chi.URLParam(r, "slug") isPreview := r.URL.Query().Get("preview") == "true" db, err := s.tenantPool.Get(tenantID) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } q := tenant.NewQueries(db) if isPreview && !s.canPreview(r, tenantID) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if !isPreview { path := "/posts/" + slug s.recordPageView(q, r, path, slug) if html, etag, err := q.GetPage(r.Context(), path); err == nil && html != nil { s.servePreRendered(w, r, html, etag, "public, s-maxage=31536000, max-age=0") return } } post, err := q.GetPost(r.Context(), slug) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if post == nil { aliasPost, _ := q.GetPostByAlias(r.Context(), slug) if aliasPost != nil && aliasPost.IsPublished { http.Redirect(w, r, "/posts/"+aliasPost.Slug, http.StatusMovedPermanently) return } http.NotFound(w, r) return } if !post.IsPublished && !isPreview { http.NotFound(w, r) return } title := post.Title description := post.Description contentMD := post.ContentMD tags := post.Tags coverImage := post.CoverImage if isPreview { if draft, _ := q.GetDraft(r.Context(), post.ID); draft != nil { title = draft.Title description = draft.Description contentMD = draft.ContentMD tags = draft.Tags coverImage = draft.CoverImage } } settings, _ := q.GetSettings(r.Context()) siteName := getSettingOr(settings, "site_name", "My Blog") baseURL := getBaseURL(r.Host) codeTheme := getSettingOr(settings, "code_theme", "github") showBadge := true if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil { tierInfo := config.GetTierInfo(t.Premium) showBadge = tierInfo.Config.BadgeRequired } contentHTML := "" if contentMD != "" { contentHTML, _ = markdown.RenderWithTheme(contentMD, codeTheme) } interactionConfig := q.GetInteractionConfig(r.Context()) structuredData := buildArticleSchema(post, siteName, baseURL) data := templates.PostData{ PageData: templates.PageData{ Title: title + " - " + siteName, Description: description, CanonicalURL: baseURL + "/posts/" + post.Slug, OGType: "article", OGImage: coverImage, SiteName: siteName, Year: time.Now().Year(), StructuredData: template.JS(structuredData), Settings: settingsToMap(settings), NoIndex: GetDemoInfo(r).IsDemo || isPreview, ShowBadge: showBadge, }, Post: templates.PostDetail{ Slug: post.Slug, Title: title, Description: description, CoverImage: coverImage, Date: timeOrZero(post.PublishedAt), Tags: tags, }, ContentHTML: template.HTML(contentHTML), InteractionConfig: interactionConfig, } html, err := templates.RenderPost(data) if err != nil { http.Error(w, "render error", http.StatusInternalServerError) return } if isPreview { previewScript := `