clearlove2.1
1// 应用型插件的 HTTP 分发。
2//
3// /x/<slug>/... 前台路由(auth: none | user | admin)
4// /x/<slug>/assets/... 插件静态资源(只读)
5// /admin/plugins/<slug>/... 后台页面(强制管理员 + perm 校验)
6package plugin
7
8import (
9 "context"
10 "encoding/json"
11 "io"
12 "net/http"
13 "os"
14 "path/filepath"
15 "strings"
16 "time"
17
18 "clearlove/internal/util"
19)
20
21func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
22 return context.WithTimeout(context.Background(), d)
23}
24
25const maxPluginBody = 1 << 20 // 插件请求体读取上限 1MB
26
27// AppHandler 前台插件路由分发器
28func AppHandler() http.Handler {
29 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30 rest := strings.TrimPrefix(r.URL.Path, "/x/")
31 slug, sub := splitSlugPath(rest)
32 app := AppBySlug(slug)
33 if app == nil {
34 http.NotFound(w, r)
35 return
36 }
37 // 静态资源:/x/<slug>/assets/xxx
38 if strings.HasPrefix(sub, "/assets/") {
39 servePluginAsset(w, r, app, strings.TrimPrefix(sub, "/assets/"))
40 return
41 }
42 route, ok := matchRoute(app, r.Method, sub)
43 if !ok {
44 http.NotFound(w, r)
45 return
46 }
47 if !checkAuth(w, r, route.Auth) {
48 return
49 }
50 serveHandler(w, r, app, route.Fn, route.JSON, nil)
51 })
52}
53
54// AdminAppHandler 后台插件页面分发器
55func AdminAppHandler() http.Handler {
56 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57 if viewProvider == nil {
58 http.Error(w, "服务未就绪", http.StatusServiceUnavailable)
59 return
60 }
61 if viewProvider.AdminID(r) == 0 {
62 http.Redirect(w, r, "/admin/login", http.StatusFound)
63 return
64 }
65 rest := strings.TrimPrefix(r.URL.Path, "/admin/plugins/")
66 slug, sub := splitSlugPath(rest)
67 app := AppBySlug(slug)
68 if app == nil {
69 http.NotFound(w, r)
70 return
71 }
72 fn, ok := AdminPageFn(slug, sub)
73 if !ok {
74 http.NotFound(w, r)
75 return
76 }
77 // 页面权限:未声明 perm 时按 plugins 权限组处理
78 perm := "plugins"
79 for _, p := range app.Pages {
80 if p.Path == sub {
81 if strings.TrimSpace(p.Perm) != "" {
82 perm = p.Perm
83 }
84 break
85 }
86 }
87 if !viewProvider.AdminPerm(r, perm) {
88 http.Error(w, "没有操作权限", http.StatusForbidden)
89 return
90 }
91 serveHandler(w, r, app, fn, false, map[string]string{"admin": "1"})
92 })
93}
94
95// ---------- 内部实现 ----------
96
97// splitSlugPath 拆出 slug 与剩余路径
98func splitSlugPath(rest string) (slug, sub string) {
99 rest = strings.Trim(rest, "/")
100 if rest == "" {
101 return "", "/"
102 }
103 if i := strings.Index(rest, "/"); i >= 0 {
104 slug, sub = rest[:i], rest[i:]
105 } else {
106 slug, sub = rest, "/"
107 }
108 if !strings.HasPrefix(sub, "/") {
109 sub = "/" + sub
110 }
111 if len(sub) > 1 {
112 sub = strings.TrimRight(sub, "/")
113 if sub == "" {
114 sub = "/"
115 }
116 }
117 return slug, sub
118}
119
120func matchRoute(a *App, method, sub string) (*RouteReg, bool) {
121 for i := range a.Routes {
122 rt := &a.Routes[i]
123 if !methodEqual(rt.Method, method) {
124 continue
125 }
126 if pathEqual(rt.Path, sub) {
127 return rt, true
128 }
129 }
130 return nil, false
131}
132
133func checkAuth(w http.ResponseWriter, r *http.Request, auth string) bool {
134 if viewProvider == nil {
135 return true
136 }
137 switch strings.ToLower(strings.TrimSpace(auth)) {
138 case "user":
139 if viewProvider.UserID(r) == 0 {
140 next := r.URL.Path
141 if r.URL.RawQuery != "" {
142 next += "?" + r.URL.RawQuery
143 }
144 http.Redirect(w, r, "/login?next="+next, http.StatusFound)
145 return false
146 }
147 case "admin":
148 if viewProvider.AdminID(r) == 0 {
149 http.Error(w, "需要管理员身份", http.StatusForbidden)
150 return false
151 }
152 }
153 return true
154}
155
156// serveHandler 调用插件 handler 并写出响应
157func serveHandler(w http.ResponseWriter, r *http.Request, app *App, fn string, isJSON bool, extra map[string]string) {
158 if app.Worker == nil || app.Worker.Closed() {
159 http.Error(w, "插件未运行", http.StatusServiceUnavailable)
160 return
161 }
162 ctxMap := buildRequestCtx(r, extra)
163
164 ctx, cancel := contextWithTimeout(app.Plugin.Timeout())
165 defer cancel()
166 v, err := app.Worker.Do(ctx, func(rt *jsRuntime) (any, error) {
167 rt.cur = ctxToReqContext(r, ctxMap)
168 defer func() { rt.cur = nil }()
169 return rt.callFn(fn, ctxMap)
170 })
171 if err != nil {
172 noteFailure(app, err)
173 logPlugin(app.Plugin.SlugOf(), "route:"+fn, "执行失败: "+err.Error(), 0)
174 if isJSON {
175 writeJSONError(w, http.StatusInternalServerError, "插件执行失败")
176 return
177 }
178 http.Error(w, "插件执行失败", http.StatusInternalServerError)
179 return
180 }
181 noteSuccess(app)
182 writeResult(w, r, app, fn, v, isJSON)
183}
184
185func writeResult(w http.ResponseWriter, r *http.Request, app *App, fn string, v any, isJSON bool) {
186 m, ok := v.(map[string]any)
187 if !ok {
188 if isJSON {
189 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
190 return
191 }
192 w.WriteHeader(http.StatusOK)
193 return
194 }
195 for k, hv := range m {
196 if k == "headers" {
197 if hdrs, ok := hv.(map[string]any); ok {
198 for hk, hvv := range hdrs {
199 w.Header().Set(hk, strOf(hvv))
200 }
201 }
202 }
203 }
204 status := intOf(m["status"])
205 if status <= 0 {
206 status = http.StatusOK
207 }
208 if u := strOf(m["redirect"]); u != "" {
209 http.Redirect(w, r, u, http.StatusSeeOther)
210 return
211 }
212 if j, ok := m["json"]; ok {
213 writeJSON(w, status, j)
214 return
215 }
216 if tpl := strOf(m["template"]); tpl != "" {
217 if viewProvider == nil {
218 http.Error(w, "模板能力未就绪", http.StatusServiceUnavailable)
219 return
220 }
221 if strings.Contains(tpl, "..") || strings.HasPrefix(tpl, "/") {
222 http.Error(w, "非法模板名", http.StatusBadRequest)
223 return
224 }
225 html, err := viewProvider.RenderTemplate(os.DirFS(filepath.Join(dir(), app.Dir)), tpl, m["data"])
226 if err != nil {
227 noteFailure(app, err)
228 http.Error(w, "模板渲染失败", http.StatusInternalServerError)
229 return
230 }
231 w.Header().Set("Content-Type", "text/html; charset=utf-8")
232 w.WriteHeader(status)
233 _, _ = w.Write([]byte(html))
234 return
235 }
236 if body := strOf(m["body"]); body != "" {
237 if w.Header().Get("Content-Type") == "" {
238 w.Header().Set("Content-Type", "text/html; charset=utf-8")
239 }
240 w.WriteHeader(status)
241 _, _ = w.Write([]byte(body))
242 return
243 }
244 _ = fn
245 w.WriteHeader(status)
246}
247
248// buildRequestCtx 构造传给脚本的 ctx 对象
249func buildRequestCtx(r *http.Request, params map[string]string) map[string]any {
250 query := map[string]string{}
251 for k, v := range r.URL.Query() {
252 if len(v) > 0 {
253 query[k] = v[0]
254 }
255 }
256 form := map[string]string{}
257 _ = r.ParseForm()
258 for k, v := range r.PostForm {
259 if len(v) > 0 {
260 form[k] = v[0]
261 }
262 }
263 var jsonBody any
264 if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
265 if b, err := io.ReadAll(io.LimitReader(r.Body, maxPluginBody)); err == nil && len(b) > 0 {
266 _ = json.Unmarshal(b, &jsonBody)
267 }
268 }
269 ctxMap := map[string]any{
270 "req": map[string]any{
271 "method": r.Method,
272 "path": r.URL.Path,
273 "query": query,
274 "form": form,
275 "json": jsonBody,
276 "ip": util.ClientIP(r),
277 "fingerprint": util.FingerprintOf(r),
278 },
279 "params": params,
280 }
281 if viewProvider != nil {
282 uid := viewProvider.UserID(r)
283 aid := viewProvider.AdminID(r)
284 ctxMap["userId"] = uid
285 ctxMap["adminId"] = aid
286 ctxMap["isAdmin"] = aid > 0
287 ctxMap["csrf"] = viewProvider.CSRF(r)
288 }
289 return ctxMap
290}
291
292func ctxToReqContext(r *http.Request, ctxMap map[string]any) *reqContext {
293 rc := &reqContext{Method: r.Method, Path: r.URL.Path}
294 if v, ok := ctxMap["userId"].(int64); ok {
295 rc.UserID = v
296 }
297 if v, ok := ctxMap["adminId"].(int64); ok {
298 rc.AdminID = v
299 rc.IsAdmin = v > 0
300 }
301 if p, ok := ctxMap["params"].(map[string]string); ok {
302 rc.Params = p
303 }
304 if req, ok := ctxMap["req"].(map[string]any); ok {
305 if q, ok := req["query"].(map[string]string); ok {
306 rc.Query = q
307 }
308 if f, ok := req["form"].(map[string]string); ok {
309 rc.Form = f
310 }
311 rc.IP = strOf(req["ip"])
312 rc.Fingerprint = strOf(req["fingerprint"])
313 }
314 rc.CSRF = strOf(ctxMap["csrf"])
315 return rc
316}
317
318// servePluginAsset 插件静态资源(仅 assets 目录,防穿越)
319func servePluginAsset(w http.ResponseWriter, r *http.Request, app *App, rel string) {
320 rel = strings.TrimPrefix(rel, "/")
321 if rel == "" || strings.Contains(rel, "..") {
322 http.NotFound(w, r)
323 return
324 }
325 full := filepath.Join(dir(), app.Dir, "assets", filepath.FromSlash(rel))
326 info, err := os.Stat(full)
327 if err != nil || info.IsDir() {
328 http.NotFound(w, r)
329 return
330 }
331 http.ServeFile(w, r, full)
332}
333
334func writeJSON(w http.ResponseWriter, status int, v any) {
335 w.Header().Set("Content-Type", "application/json; charset=utf-8")
336 w.WriteHeader(status)
337 _ = json.NewEncoder(w).Encode(v)
338}
339
340func writeJSONError(w http.ResponseWriter, status int, msg string) {
341 writeJSON(w, status, map[string]any{"ok": false, "msg": msg})
342}