| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "html/template" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "fmt" |
| 8 | ) |
| 9 | |
| 10 | func (a *App) site() siteContext { |
| 11 | blogTitle, _ := getSetting(a.DB, "blog_title") |
| 12 | if blogTitle == "" { |
| 13 | blogTitle = "My Blog" |
| 14 | } |
| 15 | navLinksRaw, _ := getSetting(a.DB, "nav_links") |
| 16 | favicon, _ := getSetting(a.DB, "favicon_url") |
| 17 | ogImage, _ := getSetting(a.DB, "og_image_url") |
| 18 | header, _ := getSetting(a.DB, "custom_header") |
| 19 | footer, _ := getSetting(a.DB, "custom_footer") |
| 20 | return siteContext{ |
| 21 | BlogTitle: blogTitle, |
| 22 | NavLinks: parseNavLinks(navLinksRaw), |
| 23 | FaviconURL: favicon, |
| 24 | OGImageURL: ogImage, |
| 25 | SiteURL: a.SiteURL, |
| 26 | HeaderHTML: template.HTML(renderMarkdown(header)), |
| 27 | FooterHTML: template.HTML(renderMarkdown(footer)), |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | func renderLatestPostsEmbed(posts []Post) string { |
| 32 | var b strings.Builder |
| 33 | b.WriteString(`<div class="post-list">`) |
| 34 | for i := range posts { |
| 35 | p := &posts[i] |
| 36 | b.WriteString(`<a href="/posts/` + p.Slug + `" class="post-item"><div class="post-item-info"><span class="post-title">` + p.DisplayTitle() + `</span>`) |
| 37 | if p.Tags != nil && *p.Tags != "" { |
| 38 | b.WriteString(`<span class="post-tags">`) |
| 39 | for _, t := range strings.Split(*p.Tags, ",") { |
| 40 | if v := strings.TrimSpace(t); v != "" { |
| 41 | b.WriteString(`<span class="tag">` + v + `</span>`) |
| 42 | } |
| 43 | } |
| 44 | b.WriteString(`</span>`) |
| 45 | } |
| 46 | b.WriteString(`</div>`) |
| 47 | if p.PublishedDate != nil { |
| 48 | b.WriteString(`<time class="post-date">` + *p.PublishedDate + `</time>`) |
| 49 | } |
| 50 | b.WriteString(`</a>`) |
| 51 | } |
| 52 | b.WriteString(`</div>`) |
| 53 | return b.String() |
| 54 | } |
| 55 | |
| 56 | func (a *App) publicIndex(w http.ResponseWriter, r *http.Request) { |
| 57 | ctx := a.site() |
| 58 | blogDesc, _ := getSetting(a.DB, "blog_description") |
| 59 | intro, _ := getSetting(a.DB, "intro_content") |
| 60 | |
| 61 | posts, err := getPublishedPosts(a.DB, 0) |
| 62 | if err != nil { |
| 63 | a.Log.Error("list posts", "err", err) |
| 64 | http.Error(w, "Server error", http.StatusInternalServerError) |
| 65 | return |
| 66 | } |
| 67 | |
| 68 | introHTML := renderMarkdown(intro) |
| 69 | if strings.Contains(intro, "{{latest_posts}}") { |
| 70 | take := len(posts) |
| 71 | if take > 5 { |
| 72 | take = 5 |
| 73 | } |
| 74 | embed := renderLatestPostsEmbed(posts[:take]) |
| 75 | introHTML = strings.ReplaceAll(introHTML, "<p>{{latest_posts}}</p>", embed) |
| 76 | introHTML = strings.ReplaceAll(introHTML, "{{latest_posts}}", embed) |
| 77 | } |
| 78 | |
| 79 | a.renderPage(w, "index.html", indexPageData{ |
| 80 | BlogTitle: ctx.BlogTitle, BlogDescription: blogDesc, |
| 81 | IntroHTML: template.HTML(introHTML), |
| 82 | Posts: posts, |
| 83 | NavLinks: ctx.NavLinks, FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
| 84 | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, |
| 85 | }) |
| 86 | } |
| 87 | |
| 88 | func (a *App) publicPost(w http.ResponseWriter, r *http.Request) { |
| 89 | slug := r.PathValue("slug") |
| 90 | post, err := getPostBySlug(a.DB, slug) |
| 91 | if err != nil { |
| 92 | http.Error(w, "Server error", http.StatusInternalServerError) |
| 93 | return |
| 94 | } |
| 95 | if post == nil || post.Status != "published" { |
| 96 | http.Error(w, "Not found", http.StatusNotFound) |
| 97 | return |
| 98 | } |
| 99 | var weather Weather |
| 100 | if post.Weather != nil { |
| 101 | w, err := formatWeather(*post.Weather) |
| 102 | if err != nil { |
| 103 | fmt.Printf("Problem formatting weather: %s", err.Error()) |
| 104 | } else { |
| 105 | weather = w |
| 106 | } |
| 107 | } |
| 108 | ctx := a.site() |
| 109 | rendered := renderMarkdown(post.Content) |
| 110 | a.renderPage(w, "post.html", postPageData{ |
| 111 | BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Post: *post, |
| 112 | RenderedContent: template.HTML(rendered), |
| 113 | FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
| 114 | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, Weather: weather, |
| 115 | }) |
| 116 | } |
| 117 | |
| 118 | func (a *App) publicPage(w http.ResponseWriter, r *http.Request) { |
| 119 | slug := r.PathValue("slug") |
| 120 | page, err := getPageBySlug(a.DB, slug) |
| 121 | if err != nil { |
| 122 | http.Error(w, "Server error", http.StatusInternalServerError) |
| 123 | return |
| 124 | } |
| 125 | if page == nil || !page.IsPublished { |
| 126 | // fallback: alias redirect or 404 |
| 127 | if redirect, err := findAliasRedirect(a.DB, slug); err == nil && redirect != "" { |
| 128 | http.Redirect(w, r, redirect, http.StatusMovedPermanently) |
| 129 | return |
| 130 | } |
| 131 | http.Error(w, "Not found", http.StatusNotFound) |
| 132 | return |
| 133 | } |
| 134 | ctx := a.site() |
| 135 | rendered := renderMarkdown(page.Content) |
| 136 | a.renderPage(w, "page.html", pagePageData{ |
| 137 | BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Page: *page, |
| 138 | RenderedContent: template.HTML(rendered), |
| 139 | FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
| 140 | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, |
| 141 | }) |
| 142 | } |
| 143 | |
| 144 | func (a *App) publicPostsList(w http.ResponseWriter, r *http.Request) { |
| 145 | ctx := a.site() |
| 146 | posts, err := getPublishedPosts(a.DB, 0) |
| 147 | if err != nil { |
| 148 | http.Error(w, "Server error", http.StatusInternalServerError) |
| 149 | return |
| 150 | } |
| 151 | a.renderPage(w, "posts.html", postsListPageData{ |
| 152 | BlogTitle: ctx.BlogTitle, NavLinks: ctx.NavLinks, Posts: posts, |
| 153 | FaviconURL: ctx.FaviconURL, OGImageURL: ctx.OGImageURL, |
| 154 | SiteURL: ctx.SiteURL, HeaderHTML: ctx.HeaderHTML, FooterHTML: ctx.FooterHTML, |
| 155 | }) |
| 156 | } |
| 157 | |
| 158 | func (a *App) customCSS(w http.ResponseWriter, r *http.Request) { |
| 159 | css, _ := getSetting(a.DB, "custom_css") |
| 160 | w.Header().Set("Content-Type", "text/css") |
| 161 | _, _ = w.Write([]byte(css)) |
| 162 | } |
| 163 | |
| 164 | func (a *App) serveUploadedFile(w http.ResponseWriter, r *http.Request) { |
| 165 | filename := r.PathValue("filename") |
| 166 | if strings.Contains(filename, "..") || strings.ContainsAny(filename, "/\\") { |
| 167 | http.NotFound(w, r) |
| 168 | return |
| 169 | } |
| 170 | f, _ := getFileByFilename(a.DB, filename) |
| 171 | if f != nil && f.StorageBackend == "r2" { |
| 172 | if a.Storage != nil && a.Storage.Name() == "r2" { |
| 173 | if u := a.Storage.PublicURL(filename); u != "" { |
| 174 | http.Redirect(w, r, u, http.StatusTemporaryRedirect) |
| 175 | return |
| 176 | } |
| 177 | } |
| 178 | http.NotFound(w, r) |
| 179 | return |
| 180 | } |
| 181 | path := a.UploadsDir + "/" + filename |
| 182 | data, err := readFile(path) |
| 183 | if err != nil { |
| 184 | http.NotFound(w, r) |
| 185 | return |
| 186 | } |
| 187 | ct := mimeFromPath(filename) |
| 188 | if f != nil && f.ContentType != "" { |
| 189 | ct = f.ContentType |
| 190 | } |
| 191 | w.Header().Set("Content-Type", ct) |
| 192 | _, _ = w.Write(data) |
| 193 | } |
| 194 | |
| 195 | func (a *App) rssFeed(w http.ResponseWriter, r *http.Request) { |
| 196 | blogTitle, _ := getSetting(a.DB, "blog_title") |
| 197 | if blogTitle == "" { |
| 198 | blogTitle = "My Blog" |
| 199 | } |
| 200 | blogDesc, _ := getSetting(a.DB, "blog_description") |
| 201 | posts, err := getPublishedPosts(a.DB, 0) |
| 202 | if err != nil { |
| 203 | http.Error(w, "Server error", http.StatusInternalServerError) |
| 204 | return |
| 205 | } |
| 206 | var items strings.Builder |
| 207 | for _, p := range posts { |
| 208 | link := a.SiteURL + "/posts/" + xmlEscape(p.Slug) |
| 209 | title := "" |
| 210 | if p.Title != nil { |
| 211 | if t := strings.TrimSpace(*p.Title); t != "" { |
| 212 | title = xmlEscape(t) |
| 213 | } |
| 214 | } |
| 215 | desc := "" |
| 216 | if p.MetaDescription != nil && *p.MetaDescription != "" { |
| 217 | desc = xmlEscape(*p.MetaDescription) |
| 218 | } else { |
| 219 | runes := []rune(p.Content) |
| 220 | n := 200 |
| 221 | if len(runes) < n { |
| 222 | n = len(runes) |
| 223 | } |
| 224 | desc = xmlEscape(string(runes[:n])) |
| 225 | } |
| 226 | rawDate := p.CreatedAt |
| 227 | if p.PublishedDate != nil { |
| 228 | rawDate = *p.PublishedDate |
| 229 | } |
| 230 | pubDate := toRFC2822(rawDate) |
| 231 | items.WriteString(" <item>\n <title>" + title + "</title>\n <link>" + link + "</link>\n <guid>" + link + "</guid>\n <description>" + desc + "</description>\n <pubDate>" + pubDate + "</pubDate>\n </item>\n") |
| 232 | } |
| 233 | lastBuild := "" |
| 234 | if len(posts) > 0 && posts[0].PublishedDate != nil { |
| 235 | lastBuild = toRFC2822(*posts[0].PublishedDate) |
| 236 | } |
| 237 | out := `<?xml version="1.0" encoding="UTF-8"?> |
| 238 | <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> |
| 239 | <channel> |
| 240 | <title>` + xmlEscape(blogTitle) + `</title> |
| 241 | <link>` + a.SiteURL + `</link> |
| 242 | <description>` + xmlEscape(blogDesc) + `</description> |
| 243 | <lastBuildDate>` + lastBuild + `</lastBuildDate> |
| 244 | <atom:link href="` + a.SiteURL + `/feed.xml" rel="self" type="application/rss+xml"/> |
| 245 | ` + items.String() + ` </channel> |
| 246 | </rss>` |
| 247 | w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") |
| 248 | _, _ = w.Write([]byte(out)) |
| 249 | } |
| 250 | |
| 251 | func readFile(path string) ([]byte, error) { |
| 252 | return readFileImpl(path) |
| 253 | } |