init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
703
internal/server/blog.go
Normal file
703
internal/server/blog.go
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
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, max-age=300")
|
||||
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, max-age=300")
|
||||
}
|
||||
|
||||
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, max-age=300")
|
||||
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, max-age=300")
|
||||
}
|
||||
|
||||
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, max-age=3600")
|
||||
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 := `<style>
|
||||
.preview-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: #fff;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
z-index: 99999;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
@media(min-width:640px) {
|
||||
.preview-banner {
|
||||
bottom: auto;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 0 0 8px 8px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
}
|
||||
.preview-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.preview-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.preview-status {
|
||||
opacity: 0.9;
|
||||
font-size: 12px;
|
||||
}
|
||||
.preview-link {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.preview-link:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.preview-rebuild {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: system-ui, sans-serif;
|
||||
z-index: 99998;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.preview-rebuild.visible { opacity: 1; }
|
||||
</style>
|
||||
<div class="preview-banner">
|
||||
<span class="preview-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
Preview Mode
|
||||
</span>
|
||||
<span class="preview-status">Viewing as author</span>
|
||||
<a class="preview-link" href="/studio/posts/` + post.Slug + `/edit">Back to Editor</a>
|
||||
</div>
|
||||
<div class="preview-rebuild" id="preview-rebuild">Rebuilding...</div>
|
||||
<script>
|
||||
(function() {
|
||||
var channel = new BroadcastChannel('writekit-preview');
|
||||
var slug = '` + post.Slug + `';
|
||||
var rebuild = document.getElementById('preview-rebuild');
|
||||
channel.onmessage = function(e) {
|
||||
if (e.data.slug !== slug) return;
|
||||
if (e.data.type === 'rebuilding') {
|
||||
rebuild.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
if (e.data.type === 'content-update') {
|
||||
var content = document.querySelector('.post-content');
|
||||
if (content) content.innerHTML = e.data.html;
|
||||
var title = document.querySelector('h1');
|
||||
if (title) title.textContent = e.data.title;
|
||||
var desc = document.querySelector('meta[name="description"]');
|
||||
if (desc) desc.content = e.data.description;
|
||||
rebuild.classList.remove('visible');
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>`
|
||||
html = bytes.Replace(html, []byte("</body>"), []byte(previewScript+"</body>"), 1)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(html)
|
||||
return
|
||||
}
|
||||
|
||||
s.servePreRendered(w, r, html, computeETag(html), "public, max-age=3600")
|
||||
}
|
||||
|
||||
func (s *Server) canPreview(r *http.Request, tenantID string) bool {
|
||||
if GetDemoInfo(r).IsDemo {
|
||||
return true
|
||||
}
|
||||
|
||||
userID := auth.GetUserID(r)
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return isOwner
|
||||
}
|
||||
|
||||
func (s *Server) serveStudio(w http.ResponseWriter, r *http.Request) {
|
||||
if viteURL := os.Getenv("VITE_URL"); viteURL != "" && os.Getenv("ENV") == "local" {
|
||||
target, err := url.Parse(viteURL)
|
||||
if err != nil {
|
||||
slog.Error("invalid VITE_URL", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
proxy.Director = func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.Host = target.Host
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
path := chi.URLParam(r, "*")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
|
||||
data, err := studio.Read(path)
|
||||
if err != nil {
|
||||
data, _ = studio.Read("index.html")
|
||||
}
|
||||
|
||||
contentType := "text/html; charset=utf-8"
|
||||
if len(path) > 3 {
|
||||
switch path[len(path)-3:] {
|
||||
case ".js":
|
||||
contentType = "application/javascript"
|
||||
case "css":
|
||||
contentType = "text/css"
|
||||
}
|
||||
}
|
||||
|
||||
if contentType == "text/html; charset=utf-8" {
|
||||
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
|
||||
data = s.injectDemoBanner(data, demoInfo.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
if contentType == "text/html; charset=utf-8" {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) sitemap(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)
|
||||
posts, _ := q.ListPosts(r.Context(), false)
|
||||
baseURL := getBaseURL(r.Host)
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>` + baseURL + `/</loc></url>
|
||||
`))
|
||||
|
||||
for _, p := range posts {
|
||||
lastmod := p.ModifiedAt.Format("2006-01-02")
|
||||
if p.UpdatedAt != nil {
|
||||
lastmod = p.UpdatedAt.Format("2006-01-02")
|
||||
}
|
||||
w.Write([]byte(fmt.Sprintf(" <url><loc>%s/posts/%s</loc><lastmod>%s</lastmod></url>\n",
|
||||
baseURL, p.Slug, lastmod)))
|
||||
}
|
||||
|
||||
w.Write([]byte("</urlset>"))
|
||||
}
|
||||
|
||||
func (s *Server) robots(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
|
||||
if GetDemoInfo(r).IsDemo {
|
||||
w.Write([]byte("User-agent: *\nDisallow: /\n"))
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := getBaseURL(r.Host)
|
||||
fmt.Fprintf(w, "User-agent: *\nAllow: /\n\nSitemap: %s/sitemap.xml\n", baseURL)
|
||||
}
|
||||
|
||||
func (s *Server) ownerMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
demoInfo := GetDemoInfo(r)
|
||||
if demoInfo.IsDemo {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := auth.GetUserID(r)
|
||||
if userID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, ok := r.Context().Value(tenantIDKey).(string)
|
||||
if !ok || tenantID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !isOwner {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func getSettingOr(settings tenant.Settings, key, fallback string) string {
|
||||
if v, ok := settings[key]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func settingsToMap(settings tenant.Settings) map[string]any {
|
||||
m := make(map[string]any)
|
||||
for k, v := range settings {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func getBaseURL(host string) string {
|
||||
scheme := "https"
|
||||
if env := os.Getenv("ENV"); env != "prod" {
|
||||
scheme = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
func computeETag(data []byte) string {
|
||||
hash := md5.Sum(data)
|
||||
return `"` + hex.EncodeToString(hash[:]) + `"`
|
||||
}
|
||||
|
||||
func (s *Server) servePreRendered(w http.ResponseWriter, r *http.Request, html []byte, etag, cacheControl string) {
|
||||
if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
|
||||
html = s.injectDemoBanner(html, demoInfo.ExpiresAt)
|
||||
etag = computeETag(html)
|
||||
}
|
||||
|
||||
if match := r.Header.Get("If-None-Match"); match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", cacheControl)
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Write(html)
|
||||
}
|
||||
|
||||
func buildArticleSchema(post *tenant.Post, siteName, baseURL string) string {
|
||||
publishedAt := timeOrZero(post.PublishedAt)
|
||||
modifiedAt := publishedAt
|
||||
if post.UpdatedAt != nil {
|
||||
modifiedAt = *post.UpdatedAt
|
||||
}
|
||||
|
||||
schema := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": post.Title,
|
||||
"datePublished": publishedAt.Format(time.RFC3339),
|
||||
"dateModified": modifiedAt.Format(time.RFC3339),
|
||||
"author": map[string]any{
|
||||
"@type": "Person",
|
||||
"name": siteName,
|
||||
},
|
||||
"publisher": map[string]any{
|
||||
"@type": "Organization",
|
||||
"name": siteName,
|
||||
},
|
||||
"mainEntityOfPage": map[string]any{
|
||||
"@type": "WebPage",
|
||||
"@id": baseURL + "/posts/" + post.Slug,
|
||||
},
|
||||
}
|
||||
if post.Description != "" {
|
||||
schema["description"] = post.Description
|
||||
}
|
||||
b, _ := json.Marshal(schema)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (s *Server) recordPageView(q *tenant.Queries, r *http.Request, path, postSlug string) {
|
||||
referrer := r.Header.Get("Referer")
|
||||
userAgent := r.Header.Get("User-Agent")
|
||||
go func() {
|
||||
q.RecordPageView(context.Background(), path, postSlug, referrer, userAgent)
|
||||
}()
|
||||
}
|
||||
|
||||
func timeOrZero(t *time.Time) time.Time {
|
||||
if t == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *t
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue