clearlove2.1
1// Package middleware 全局中间件:安全响应头、安装门禁、访问日志、
2// CSRF 防护(双提交 Cookie)、发帖封禁检查。
3package middleware
4
5import (
6 "context"
7 "encoding/json"
8 "net/http"
9 "net/url"
10 "strings"
11 "time"
12
13 "clearlove/internal/config"
14 "clearlove/internal/database"
15 "clearlove/internal/models"
16 "clearlove/internal/plugin"
17 "clearlove/internal/util"
18)
19
20type ctxKey string
21
22const csrfKey ctxKey = "csrf"
23
24// CSRFToken 从请求上下文取出 CSRF 令牌(handlers 渲染表单时使用)
25func CSRFToken(r *http.Request) string {
26 if v, ok := r.Context().Value(csrfKey).(string); ok {
27 return v
28 }
29 return ""
30}
31
32// Use 全局中间件入口
33func Use(next http.Handler) http.Handler {
34 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35 start := time.Now()
36
37 // 安全响应头
38 w.Header().Set("X-Content-Type-Options", "nosniff")
39 w.Header().Set("X-Frame-Options", "SAMEORIGIN")
40 w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
41
42 // 安装门禁:未安装只放行安装向导与页面所需静态资源
43 // (theme.css 必须放行,否则主题变量缺失会导致按钮等样式失效)
44 if !config.Cfg.Installed {
45 p := r.URL.Path
46 ok := p == "/install" || p == "/theme.css" || p == "/favicon.svg" ||
47 strings.HasPrefix(p, "/static/") || strings.HasPrefix(p, "/uploads/")
48 if !ok {
49 http.Redirect(w, r, "/install", http.StatusFound)
50 return
51 }
52 } else if r.URL.Path == "/install" {
53 http.Redirect(w, r, "/", http.StatusFound)
54 return
55 }
56
57 // 应用型插件中间件(http.before):在 CSRF 校验之前执行,可短路响应
58 if plugin.HasApps() && !strings.HasPrefix(r.URL.Path, "/static/") && !strings.HasPrefix(r.URL.Path, "/uploads/") {
59 if res := plugin.RunMiddlewareBefore(map[string]any{
60 "method": r.Method,
61 "path": r.URL.Path,
62 "query": firstValues(r.URL.Query()),
63 "ip": util.ClientIP(r),
64 "fingerprint": util.FingerprintOf(r),
65 }); res != nil {
66 for k, v := range res.Headers {
67 w.Header().Set(k, v)
68 }
69 switch {
70 case res.Redirect != "":
71 http.Redirect(w, r, res.Redirect, http.StatusFound)
72 case res.JSON != nil:
73 w.Header().Set("Content-Type", "application/json; charset=utf-8")
74 w.WriteHeader(res.Status)
75 _ = json.NewEncoder(w).Encode(res.JSON)
76 case res.Body != "":
77 w.WriteHeader(res.Status)
78 _, _ = w.Write([]byte(res.Body))
79 default:
80 w.WriteHeader(res.Status)
81 }
82 return
83 }
84 }
85
86 // CSRF 令牌:缺失则生成并写入 Cookie,同时放入上下文
87 token := ""
88 if c, err := r.Cookie("clv_csrf"); err == nil {
89 token = c.Value
90 }
91 fresh := false
92 if token == "" || len(token) != 32 {
93 token = util.RandomHex(16)
94 fresh = true
95 }
96 if fresh {
97 http.SetCookie(w, &http.Cookie{
98 Name: "clv_csrf", Value: token, Path: "/", MaxAge: 86400 * 30,
99 HttpOnly: true, SameSite: http.SameSiteLaxMode,
100 })
101 }
102 r = r.WithContext(context.WithValue(r.Context(), csrfKey, token))
103
104 // 写操作校验
105 if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
106 if config.Cfg.Installed && !exemptCSRF(r) {
107 v := r.Header.Get("X-CSRF-Token")
108 if v == "" {
109 // 兼容 multipart:完整解析后读取表单值
110 ct := r.Header.Get("Content-Type")
111 if strings.HasPrefix(ct, "multipart/form-data") {
112 _ = r.ParseMultipartForm(32 << 20)
113 } else {
114 _ = r.ParseForm()
115 }
116 v = r.PostFormValue("_csrf")
117 }
118 if v != token {
119 http.Error(w, "CSRF 校验失败,请刷新页面重试", http.StatusForbidden)
120 return
121 }
122 }
123 // 封禁检查(发帖/评论/API发帖)
124 p := r.URL.Path
125 if p == "/compose" || p == "/api/v1/posts" || p == "/api/v1/comments" {
126 if isBanned(util.ClientIP(r)) {
127 http.Error(w, "您的网络环境已被封禁,无法发布内容", http.StatusForbidden)
128 return
129 }
130 }
131 }
132
133 // 应用型插件的 http.after:需要拿到最终状态码,这里包装一层 ResponseWriter
134 var sw *statusWriter
135 if plugin.HasApps() {
136 sw = &statusWriter{ResponseWriter: w, status: http.StatusOK}
137 w = sw
138 }
139 next.ServeHTTP(w, r)
140 if sw != nil {
141 plugin.RunMiddlewareAfter(map[string]any{
142 "method": r.Method,
143 "path": r.URL.Path,
144 "status": sw.status,
145 "ip": util.ClientIP(r),
146 "fingerprint": util.FingerprintOf(r),
147 "duration_ms": time.Since(start).Milliseconds(),
148 })
149 }
150 util.Log("info", "%s %s %s %s", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start).Round(time.Millisecond))
151 })
152}
153
154// statusWriter 记录响应状态码(http.after 使用)
155type statusWriter struct {
156 http.ResponseWriter
157 status int
158}
159
160func (s *statusWriter) WriteHeader(code int) {
161 s.status = code
162 s.ResponseWriter.WriteHeader(code)
163}
164
165func (s *statusWriter) Flush() {
166 if f, ok := s.ResponseWriter.(http.Flusher); ok {
167 f.Flush()
168 }
169}
170
171// exemptCSRF API Key 请求(第三方客户端)无需 CSRF
172func exemptCSRF(r *http.Request) bool {
173 k := r.Header.Get("X-API-Key")
174 if k == "" {
175 return false
176 }
177 var n int64
178 err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE api_key=? AND status=1", k).Scan(&n)
179 return err == nil && n > 0
180}
181
182// firstValues 把 query 转成一维 map(供插件中间件使用)
183func firstValues(v url.Values) map[string]string {
184 out := make(map[string]string, len(v))
185 for k, list := range v {
186 if len(list) > 0 {
187 out[k] = list[0]
188 }
189 }
190 return out
191}
192
193// isBanned 检查 IP 是否在封禁名单
194func isBanned(ip string) bool {
195 var n int64
196 err := database.DB.QueryRow("SELECT COUNT(1) FROM bans WHERE btype='ip' AND bvalue=?", ip).Scan(&n)
197 return err == nil && n > 0
198}
199
200// IsFingerprintBanned 供 handlers 检查浏览器指纹封禁
201func IsFingerprintBanned(fp string) bool {
202 if fp == "" {
203 return false
204 }
205 var n int64
206 err := database.DB.QueryRow("SELECT COUNT(1) FROM bans WHERE btype='fingerprint' AND bvalue=?", fp).Scan(&n)
207 return err == nil && n > 0
208}
209
210// HasAPIKey 校验 API Key 并记录最近使用时间
211func HasAPIKey(r *http.Request) bool {
212 k := r.Header.Get("X-API-Key")
213 if k == "" {
214 return false
215 }
216 var n int64
217 if err := database.DB.QueryRow("SELECT COUNT(1) FROM apikeys WHERE api_key=? AND status=1", k).Scan(&n); err != nil || n == 0 {
218 return false
219 }
220 _, _ = database.DB.Exec("UPDATE apikeys SET last_used=? WHERE api_key=?", models.Now(), k)
221 return true
222}