clearlove2.1
1// Package handlers 全部 HTTP 处理器。
2// 本文件提供:模板初始化与渲染、会话判定(用户/管理员)、JSON 输出等公共设施。
3package handlers
4
5import (
6 "bytes"
7 "embed"
8 "encoding/json"
9 "html/template"
10 "io/fs"
11 "net/http"
12 "os"
13 "strconv"
14 "strings"
15 "time"
16
17 "clearlove/internal/config"
18 "clearlove/internal/middleware"
19 "clearlove/internal/models"
20 "clearlove/internal/plugin"
21 "clearlove/internal/util"
22)
23
24// Tmpl 全局模板集(layout.html / admin_layout.html 为布局,其余为页面片段)
25var Tmpl *template.Template
26
27// tmplFuncs 模板函数表(插件模板渲染复用同一套函数)
28var tmplFuncs template.FuncMap
29
30// StaticFS 嵌入式静态资源(由 main 注入)
31var StaticFS fs.FS
32
33// webTemplatesFS 内嵌模板源(插件覆盖时用于重新解析)
34var (
35 webTemplatesFS embed.FS
36 hasWebTemplates bool
37)
38
39// SetupTemplates 解析嵌入式模板并注册模板函数
40func SetupTemplates(efs embed.FS) {
41 funcs := template.FuncMap{
42 // nl2br 转义 HTML 后将换行转为 <br>(安全展示用户文本)
43 "nl2br": func(s string) template.HTML {
44 return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "\n", "<br>"))
45 },
46 // datefmt RFC3339 -> 本地 "MM-DD HH:mm"
47 "datefmt": func(s string) string {
48 t, err := time.Parse(time.RFC3339, s)
49 if err != nil {
50 return s
51 }
52 return t.Local().Format("01-02 15:04")
53 },
54 "snippet": func(s string, n int) string {
55 r := []rune(s)
56 if len(r) > n {
57 return string(r[:n]) + "..."
58 }
59 return s
60 },
61 // substr0 取首字符用于生成头像占位
62 "substr0": func(s string) string {
63 r := []rune(s)
64 if len(r) == 0 {
65 return "匿"
66 }
67 return string(r[0])
68 },
69 // 分页辅助
70 "plus1": func(n int) int { return n + 1 },
71 "minus1": func(n int) int { return n - 1 },
72 }
73 // pageContent 按页面名动态渲染子模板(布局中调用,等价于动态 template 包含)
74 funcs["pageContent"] = func(name string, data any) template.HTML {
75 var buf bytes.Buffer
76 if Tmpl == nil {
77 return ""
78 }
79 if err := Tmpl.ExecuteTemplate(&buf, name, data); err != nil {
80 util.Log("error", "页面模板 %s 渲染失败: %v", name, err)
81 return template.HTML("<p>页面渲染失败</p>")
82 }
83 return template.HTML(buf.String())
84 }
85 tmplFuncs = funcs
86 webTemplatesFS = efs
87 hasWebTemplates = true
88 RebuildTemplates()
89 // 把模板能力反向注入插件运行时(应用型插件的页面渲染依赖它)
90 plugin.SetViewProvider(pluginViewProvider{})
91}
92
93// RebuildTemplates 重新解析内核模板,并叠加插件目录中的同名覆盖模板。
94// Go 模板的特性:后解析的同名模板会覆盖先前的,因此插件可精准替换某个片段。
95func RebuildTemplates() {
96 if !hasWebTemplates {
97 return
98 }
99 Tmpl = template.Must(template.New("clv").Funcs(tmplFuncs).ParseFS(webTemplatesFS, "web/templates/*.html"))
100 for _, dir := range plugin.EnabledTemplateDirs() {
101 entries, err := os.ReadDir(dir)
102 if err != nil {
103 continue
104 }
105 hasHTML := false
106 for _, e := range entries {
107 if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".html") {
108 hasHTML = true
109 break
110 }
111 }
112 if !hasHTML {
113 continue
114 }
115 if _, err := Tmpl.ParseFS(os.DirFS(dir), "*.html"); err != nil {
116 util.Log("warn", "插件模板覆盖失败 %s: %v", dir, err)
117 continue
118 }
119 util.Log("info", "已应用插件模板覆盖: %s", dir)
120 }
121}
122
123// ReloadTemplates 插件启停后刷新模板(含覆盖),供 handlers 内部调用
124func ReloadTemplates() { RebuildTemplates() }
125
126// Render 渲染页面。公共数据(页面名、CSRF、站名、用户、插件注入)自动注入
127func Render(w http.ResponseWriter, r *http.Request, layout, page string, data map[string]any) {
128 if data == nil {
129 data = map[string]any{}
130 }
131 data["Page"] = page
132 data["csrf"] = middleware.CSRFToken(r)
133 data["clvVersion"] = config.Version
134 data["site"] = models.GetSetting("site_name")
135 data["bg"] = models.GetSetting("theme_bg")
136 data["donate"] = models.GetSetting("donate_img")
137 if u := CurrentUser(r); u != nil {
138 data["user"] = u
139 }
140 // 后台布局依赖 .admin 渲染侧边菜单与管理条,必须在此统一注入
141 if a := CurrentAdmin(r); a != nil {
142 data["admin"] = a
143 }
144
145 // 插件 UI 注入点:A 型静态片段 + B 型(应用型)动态渲染
146 slotData := pluginSlotData(r, page, data)
147 data["header_html"] = template.HTML(plugin.SlotHTML("header_html", slotData))
148 data["footer_html"] = template.HTML(plugin.SlotHTML("footer_html", slotData))
149 data["head_end"] = template.HTML(plugin.SlotHTML("head.end", slotData))
150 data["body_end"] = template.HTML(plugin.SlotHTML("body.end", slotData))
151 data["admin_head_end"] = template.HTML(plugin.SlotHTML("admin.head.end", slotData))
152 data["admin_body_end"] = template.HTML(plugin.SlotHTML("admin.body.end", slotData))
153 data["admin_sidebar_after"] = template.HTML(plugin.SlotHTML("admin.sidebar.after", slotData))
154 data["admin_settings_after"] = template.HTML(plugin.SlotHTML("admin.settings.after", slotData))
155 data["admin_dashboard_after"] = template.HTML(plugin.SlotHTML("admin.dashboard.after", slotData))
156 data["comments_after"] = template.HTML(plugin.SlotHTML("comments.after", slotData))
157 data["compose_after"] = template.HTML(plugin.SlotHTML("compose.after", slotData))
158 data["profile_after"] = template.HTML(plugin.SlotHTML("profile.after", slotData))
159 data["detail_actions_after"] = template.HTML(plugin.SlotHTML("post.detail.actions.after", slotData))
160 data["detail_after"] = template.HTML(plugin.SlotHTML("post.detail.after", slotData))
161 data["plugin_menus"] = plugin.AdminMenus()
162 w.Header().Set("Content-Type", "text/html; charset=utf-8")
163 if err := Tmpl.ExecuteTemplate(w, layout, data); err != nil {
164 util.Log("error", "模板渲染失败 %s: %v", page, err)
165 }
166}
167
168// JSON 输出 JSON 响应
169func JSON(w http.ResponseWriter, code int, v any) {
170 w.Header().Set("Content-Type", "application/json; charset=utf-8")
171 w.WriteHeader(code)
172 _ = json.NewEncoder(w).Encode(v)
173}
174
175// fail 输出错误 JSON
176func fail(w http.ResponseWriter, code int, msg string) {
177 JSON(w, code, map[string]any{"ok": false, "msg": msg})
178}
179
180// okJSON 输出成功 JSON(应用型插件可通过 filter.api.response 改写响应体)
181func okJSON(w http.ResponseWriter, extra map[string]any) {
182 m := map[string]any{"ok": true}
183 for k, v := range extra {
184 m[k] = v
185 }
186 if plugin.HasApps() {
187 if out := plugin.ApplyFilterValue("filter.api.response", m, nil); out != nil {
188 if mm, ok := out.(map[string]any); ok {
189 m = mm
190 }
191 }
192 }
193 JSON(w, http.StatusOK, m)
194}
195
196// pathInt64 解析路径参数为 int64
197func pathInt64(r *http.Request, name string) int64 {
198 n, _ := strconv.ParseInt(r.PathValue(name), 10, 64)
199 return n
200}
201
202// formInt64 解析表单整数值
203func formInt64(r *http.Request, key string) int64 {
204 n, _ := strconv.ParseInt(strings.TrimSpace(r.FormValue(key)), 10, 64)
205 return n
206}
207
208// CurrentUser 从签名 Cookie 解析当前登录用户
209func CurrentUser(r *http.Request) *models.User {
210 c, err := r.Cookie("clv_user")
211 if err != nil {
212 return nil
213 }
214 uid, ok := util.VerifySession(c.Value)
215 if !ok {
216 return nil
217 }
218 u := models.GetUserByID(uid)
219 if u == nil || u.Status != 1 {
220 return nil
221 }
222 return u
223}
224
225// CurrentAdmin 从签名 Cookie 解析当前管理员
226func CurrentAdmin(r *http.Request) *models.Admin {
227 c, err := r.Cookie("clv_admin")
228 if err != nil {
229 return nil
230 }
231 aid, ok := util.VerifySession(c.Value)
232 if !ok {
233 return nil
234 }
235 return models.GetAdminByID(aid)
236}
237
238// requireAdmin 后台权限门禁:未登录跳登录页,权限不足返回 403
239func requireAdmin(w http.ResponseWriter, r *http.Request, perm string) *models.Admin {
240 a := CurrentAdmin(r)
241 if a == nil {
242 http.Redirect(w, r, "/admin/login", http.StatusFound)
243 return nil
244 }
245 if perm != "" && !a.HasPerm(perm) {
246 http.Error(w, "没有操作权限", http.StatusForbidden)
247 return nil
248 }
249 return a
250}