仰望星辰工作室

clearlove2.1

clearlove2.1/ internal/handlers/store.go 5.5 KB · 179 行 原始文件
1// 插件商店:对接 ClearLove 云端插件社区(默认 https://clearlove.kazx.top)。
2// 管理员可在后台浏览云端插件市场,一键下载安装(SHA256 校验)。
3package handlers
4
5import (
6 "bytes"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "io"
11 "net/http"
12 "net/url"
13 "strings"
14 "time"
15
16 "clearlove/internal/models"
17 "clearlove/internal/plugin"
18)
19
20// defaultCloudAPI 云端默认地址(可在后台「网站设置」中修改 cloud_api)
21const defaultCloudAPI = "https://clearlove.kazx.top"
22
23// cloudBase 返回云端服务地址(去掉末尾斜杠)
24func cloudBase() string {
25 v := strings.TrimSpace(models.GetSetting("cloud_api"))
26 if v == "" {
27 return defaultCloudAPI
28 }
29 return strings.TrimSuffix(v, "/")
30}
31
32// StorePlugin 云端插件条目(与云端 /api/v1/store/plugins 返回结构一致)
33type StorePlugin struct {
34 Slug string `json:"slug"`
35 Name string `json:"name"`
36 Version string `json:"version"`
37 Description string `json:"description"`
38 Author string `json:"author"`
39 Category string `json:"category"`
40 CategoryTxt string `json:"category_label"`
41 Tags string `json:"tags"`
42 Downloads int64 `json:"downloads"`
43 Installs int64 `json:"installs"`
44 Size int64 `json:"size"`
45 SHA256 string `json:"sha256"`
46 UpdatedAt string `json:"updated_at"`
47 PageURL string `json:"page_url"`
48 DownloadURL string `json:"download_url"`
49 // 应用型插件(kind=app)的形态与权限声明,供安装前审阅
50 Kind string `json:"kind"`
51 Permissions []string `json:"permissions"`
52}
53
54// fetchStoreList 拉取云端插件列表,返回列表与错误提示。
55// kind 为空表示全部形态,可传 app(应用型)/ declarative(声明式)。
56func fetchStoreList(q, kind string) ([]StorePlugin, string) {
57 u := cloudBase() + "/api/v1/store/plugins?limit=50"
58 if q != "" {
59 u += "&q=" + url.QueryEscape(q)
60 }
61 if kind != "" {
62 u += "&kind=" + url.QueryEscape(kind)
63 }
64 client := &http.Client{Timeout: 12 * time.Second}
65 resp, err := client.Get(u)
66 if err != nil {
67 return nil, "无法连接云端插件社区(" + cloudBase() + "),请检查网络或云端地址配置"
68 }
69 defer resp.Body.Close()
70 if resp.StatusCode != http.StatusOK {
71 return nil, "云端返回状态码 " + resp.Status
72 }
73 var out struct {
74 OK bool `json:"ok"`
75 Plugins []StorePlugin `json:"plugins"`
76 }
77 if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&out); err != nil {
78 return nil, "云端返回数据解析失败"
79 }
80 return out.Plugins, ""
81}
82
83// AdminStore 插件商店页面
84func AdminStore(w http.ResponseWriter, r *http.Request) {
85 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
86 return
87 }
88 q := strings.TrimSpace(r.URL.Query().Get("q"))
89 kind := strings.TrimSpace(r.URL.Query().Get("kind"))
90 if kind != "app" && kind != "declarative" {
91 kind = ""
92 }
93 list, errMsg := fetchStoreList(q, kind)
94 // 本地已安装插件(按 plugin.json 的 name 匹配)
95 installed := map[string]bool{}
96 for _, p := range plugin.List() {
97 installed[p.Name] = true
98 }
99 appNum := 0
100 for _, sp := range list {
101 if sp.Kind == "app" {
102 appNum++
103 }
104 }
105 Render(w, r, "admin_layout.html", "pg_admin_store", map[string]any{
106 "plugins": list,
107 "installed": installed,
108 "q": q,
109 "kind": kind,
110 "appNum": appNum,
111 "cloud": cloudBase(),
112 "errMsg": errMsg,
113 "notice": r.URL.Query().Get("msg"),
114 })
115}
116
117// AdminStoreInstall 从云端下载并安装插件(校验 SHA256 后落盘)
118func AdminStoreInstall(w http.ResponseWriter, r *http.Request) {
119 if a := requireAdmin(w, r, models.PermPlugin); a == nil {
120 return
121 }
122 slug := strings.TrimSpace(r.FormValue("slug"))
123 version := strings.TrimSpace(r.FormValue("version"))
124 wantSHA := strings.TrimSpace(r.FormValue("sha256"))
125 back := func(msg string) {
126 http.Redirect(w, r, "/admin/store?err="+url.QueryEscape(msg), http.StatusSeeOther)
127 }
128 if slug == "" || version == "" {
129 back("缺少插件信息")
130 return
131 }
132 dl := cloudBase() + "/api/v1/store/download/" + url.PathEscape(slug) + "/" + url.PathEscape(version)
133 client := &http.Client{Timeout: 5 * time.Minute}
134 resp, err := client.Get(dl)
135 if err != nil {
136 back("下载失败:无法连接云端")
137 return
138 }
139 defer resp.Body.Close()
140 if resp.StatusCode != http.StatusOK {
141 back("下载失败:云端返回 " + resp.Status)
142 return
143 }
144 data, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20))
145 if err != nil || len(data) == 0 {
146 back("下载失败:文件为空或读取异常")
147 return
148 }
149 // 完整性校验
150 sum := sha256.Sum256(data)
151 if wantSHA != "" && hex.EncodeToString(sum[:]) != wantSHA {
152 back("安装包校验失败(SHA256 不匹配),已阻止安装")
153 return
154 }
155 if err := plugin.Install(slug+"-"+version+".zip", data); err != nil {
156 back("安装失败:" + err.Error())
157 return
158 }
159 go reportInstall(slug, version)
160 http.Redirect(w, r, "/admin/store?msg="+url.QueryEscape("插件「"+slug+"」安装成功,请在插件管理中启用"), http.StatusSeeOther)
161}
162
163// reportInstall 异步上报安装量(失败不影响本地安装)
164func reportInstall(slug, version string) {
165 body, _ := json.Marshal(map[string]string{
166 "slug": slug,
167 "version": version,
168 "site": models.GetSetting("site_name"),
169 })
170 client := &http.Client{Timeout: 8 * time.Second}
171 req, err := http.NewRequest(http.MethodPost, cloudBase()+"/api/v1/store/report", bytes.NewReader(body))
172 if err != nil {
173 return
174 }
175 req.Header.Set("Content-Type", "application/json")
176 if resp, err := client.Do(req); err == nil {
177 resp.Body.Close()
178 }
179}