apps/posts/handlers_public.go 7.6 K raw
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
}