返回 DeepSeek-Reasonix
theme_plugin.go
根目录 / desktop / theme_plugin.go
1 package main
2
3 import (
4 "archive/zip"
5 "bytes"
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "mime"
10 "net/http"
11 "os"
12 "path/filepath"
13 "strings"
14 "time"
15
16 "reasonix/internal/config"
17 "reasonix/internal/pluginpkg"
18 )
19
20 // Plugin themes are .reasonix-theme packs contributed by ENABLED installed
21 // plugins (Manifest v1 contributes.themes globs). They are read-only: they are
22 // never copied into the user theme library, never staged, and never mutated —
23 // every read goes straight to the ZIP inside the plugin root. The external id
24 // is plugin:<pluginName>:<themeID>; the pack manifest's own id continues to
25 // obey themePackIDRe and the plugin: prefix is added/stripped only at this
26 // seam.
27
28 const (
29 themeKindPlugin = "plugin"
30 pluginThemeIDPrefix = "plugin:"
31 )
32
33 // isPluginThemeID reports whether an external theme id carries the plugin
34 // prefix. It is intentionally prefix-only (cheap, allocation-free) so the
35 // fallback contract can recognize plugin pointers even when the remainder is
36 // malformed; parsePluginThemeID does the strict validation.
37 func isPluginThemeID(id string) bool {
38 return strings.HasPrefix(strings.TrimSpace(id), pluginThemeIDPrefix)
39 }
40
41 // parsePluginThemeID splits plugin:<pluginName>:<themeID>. Plugin names cannot
42 // contain ":" (pluginpkg.validName) and theme ids cannot either
43 // (themePackIDRe), so a single Cut on the first colon is unambiguous.
44 func parsePluginThemeID(id string) (pluginName, themeID string, ok bool) {
45 rest, found := strings.CutPrefix(strings.TrimSpace(id), pluginThemeIDPrefix)
46 if !found {
47 return "", "", false
48 }
49 pluginName, themeID, found = strings.Cut(rest, ":")
50 if !found {
51 return "", "", false
52 }
53 if !pluginpkg.IsValidName(pluginName) || !themePackIDRe.MatchString(themeID) {
54 return "", "", false
55 }
56 return pluginName, themeID, true
57 }
58
59 // pluginTheme is one validated theme pack discovered inside an enabled plugin.
60 type pluginTheme struct {
61 id string // plugin:<pluginName>:<themeID>
62 pluginName string // installed plugin name (for view badging)
63 themeID string // the pack manifest's own id
64 path string // absolute path of the .reasonix-theme ZIP
65 manifest *ThemePackManifest
66 digests map[string]string // lowercase scene image name -> content digest
67 warnings []string // non-fatal discovery issues of the same plugin
68 }
69
70 // discoverPluginThemes resolves the contributes.themes globs of every ENABLED
71 // installed plugin and validates each matched pack with the same schema v2
72 // validator and ZIP container rules the user-theme import path uses. Invalid
73 // files are skipped and reported through the returned warnings — never fatal.
74 // Disabled plugins are skipped by pluginpkg.LoadInstalled; uninstalling a
75 // plugin simply makes its themes disappear from the result.
76 func discoverPluginThemes() ([]pluginTheme, []string) {
77 pkgs, warnings := pluginpkg.LoadInstalled(config.ReasonixHomeDir())
78 var out []pluginTheme
79 seen := map[string]bool{}
80 for i := range pkgs {
81 pluginName := pkgs[i].Installed.Name
82 pluginWarningsStart := len(warnings)
83 // Parse-level theme issues (missing paths, unmatched globs) computed by
84 // pluginpkg ride along too; other capability warnings stay with the
85 // plugin views that already show them.
86 for _, w := range pkgs[i].Warnings {
87 if strings.HasPrefix(w, "theme") {
88 warnings = append(warnings, fmt.Sprintf("plugin %s: %s", pluginName, w))
89 }
90 }
91 var mine []pluginTheme
92 for _, ref := range pkgs[i].Package.Inventory().Themes {
93 m, images, err := loadPluginThemeZip(ref.Path)
94 if err != nil {
95 warnings = append(warnings, fmt.Sprintf("plugin %s: theme %s skipped: %v", pluginName, filepath.Base(ref.Path), err))
96 continue
97 }
98 id := pluginThemeIDPrefix + pluginName + ":" + m.ID
99 if seen[id] {
100 warnings = append(warnings, fmt.Sprintf("plugin %s: theme %s skipped: duplicate theme id %q", pluginName, filepath.Base(ref.Path), m.ID))
101 continue
102 }
103 seen[id] = true
104 digests := make(map[string]string, len(images))
105 for key, data := range images {
106 digests[key] = themeDataDigest(data)
107 }
108 mine = append(mine, pluginTheme{
109 id: id,
110 pluginName: pluginName,
111 themeID: m.ID,
112 path: ref.Path,
113 manifest: m,
114 digests: digests,
115 })
116 }
117 // Surface the plugin's skipped-file warnings on each of its surviving
118 // views (the same per-item pattern PluginView.Warnings uses).
119 for j := range mine {
120 mine[j].warnings = append([]string(nil), warnings[pluginWarningsStart:]...)
121 }
122 out = append(out, mine...)
123 }
124 return out, warnings
125 }
126
127 // findPluginTheme resolves one enabled plugin's contributed theme, or nil when
128 // the plugin is missing, disabled, uninstalled, or the file became invalid.
129 func findPluginTheme(pluginName, themeID string) *pluginTheme {
130 themes, _ := discoverPluginThemes()
131 for i := range themes {
132 if themes[i].pluginName == pluginName && themes[i].themeID == themeID {
133 return &themes[i]
134 }
135 }
136 return nil
137 }
138
139 // loadPluginThemeZip opens a contributed theme pack read-only and returns the
140 // validated manifest plus the raw bytes of its declared scene images. The ZIP
141 // container rules are the shared ones from scanThemeZipEntries; nothing is
142 // extracted to disk.
143 func loadPluginThemeZip(path string) (*ThemePackManifest, map[string][]byte, error) {
144 info, err := os.Lstat(path)
145 if err != nil {
146 return nil, nil, err
147 }
148 if info.Mode()&os.ModeSymlink != 0 {
149 return nil, nil, fmt.Errorf("theme package must not be a symlink")
150 }
151 if !info.Mode().IsRegular() {
152 return nil, nil, fmt.Errorf("theme package must be a regular file")
153 }
154 if info.Size() > themePackMaxZipBytes {
155 return nil, nil, fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes)
156 }
157
158 f, err := os.Open(path)
159 if err != nil {
160 return nil, nil, err
161 }
162 defer f.Close()
163
164 // Re-check size after open (TOCTOU).
165 fi, err := f.Stat()
166 if err != nil {
167 return nil, nil, err
168 }
169 if fi.Size() > themePackMaxZipBytes {
170 return nil, nil, fmt.Errorf("theme package exceeds %d bytes", themePackMaxZipBytes)
171 }
172
173 zr, err := zip.NewReader(f, fi.Size())
174 if err != nil {
175 return nil, nil, fmt.Errorf("invalid theme ZIP: %w", err)
176 }
177 manifestEntry, imageEntries, err := scanThemeZipEntries(zr)
178 if err != nil {
179 return nil, nil, err
180 }
181 raw, err := readZipFileLimited(manifestEntry, themePackMaxManifest)
182 if err != nil {
183 return nil, nil, err
184 }
185 m, err := parseThemePackManifest(raw)
186 if err != nil {
187 return nil, nil, err
188 }
189 if err := checkThemeZipImages(m, imageEntries); err != nil {
190 return nil, nil, err
191 }
192 images := make(map[string][]byte, len(imageEntries))
193 for key, zf := range imageEntries {
194 data, err := readZipFileLimited(zf, themePackMaxImageBytes)
195 if err != nil {
196 return nil, nil, err
197 }
198 images[key] = data
199 }
200 return m, images, nil
201 }
202
203 // pluginThemeView renders the frontend-safe view of a plugin theme. Kind is
204 // "plugin", the plugin name rides along for badging, and the pack is marked
205 // read-only (Builtin=false, no save/delete/rename paths accept the id).
206 func pluginThemeView(pt pluginTheme, active bool) ThemePackView {
207 m := pt.manifest
208 bgURL := ""
209 if m.Background != nil {
210 bgURL = pluginThemeBackgroundURL(pt, m.Background.Image)
211 }
212 taskURL := ""
213 if m.TaskBackground != nil {
214 taskURL = pluginThemeBackgroundURL(pt, m.TaskBackground.Image)
215 }
216 v := manifestToView(m, themeKindPlugin, active, bgURL, "", taskURL)
217 // The external id carries the plugin: prefix; the manifest's own id stays
218 // governed by themePackIDRe inside the pack.
219 v.ID = pt.id
220 v.PluginName = pt.pluginName
221 v.Warnings = append([]string(nil), pt.warnings...)
222 return v
223 }
224
225 // pluginThemeBackgroundURL builds the content-addressed asset URL for a scene
226 // image that stays inside the plugin ZIP. The digest was computed when the
227 // theme was discovered; the serve path re-verifies it on every request.
228 func pluginThemeBackgroundURL(pt pluginTheme, imageName string) string {
229 if imageName == "" {
230 return ""
231 }
232 digest := pt.digests[strings.ToLower(imageName)]
233 if digest == "" {
234 return ""
235 }
236 return themeAssetURLPrefix + pt.id + "/" + digest + "/" + filepath.Base(imageName)
237 }
238
239 // themeDataDigest is the in-memory counterpart of themeFileDigest: the same
240 // truncated SHA-256 content identity used by every theme asset URL.
241 func themeDataDigest(data []byte) string {
242 sum := sha256.Sum256(data)
243 return hex.EncodeToString(sum[:])[:16]
244 }
245
246 // servePluginThemeAsset serves a scene image straight out of the plugin ZIP.
247 // It mirrors serveOfficialThemeAsset: the theme must still resolve from an
248 // enabled plugin, the filename must be manifest-declared, the URL digest is
249 // re-verified against the current bytes, and the MIME sniff must agree with
250 // the declared extension.
251 func servePluginThemeAsset(w http.ResponseWriter, r *http.Request, pluginName, themeID, digest, filename string) {
252 pt := findPluginTheme(pluginName, themeID)
253 if pt == nil {
254 http.NotFound(w, r)
255 return
256 }
257 declared := pt.manifest.Background != nil && pt.manifest.Background.Image == filename ||
258 pt.manifest.TaskBackground != nil && pt.manifest.TaskBackground.Image == filename
259 if !declared {
260 http.NotFound(w, r)
261 return
262 }
263 _, images, err := loadPluginThemeZip(pt.path)
264 if err != nil {
265 http.NotFound(w, r)
266 return
267 }
268 data := images[strings.ToLower(filename)]
269 if len(data) == 0 {
270 http.NotFound(w, r)
271 return
272 }
273 // Re-validate file identity against the digest embedded in the URL.
274 if !strings.EqualFold(themeDataDigest(data), digest) {
275 http.NotFound(w, r)
276 return
277 }
278 head := data
279 if len(head) > 512 {
280 head = head[:512]
281 }
282 if sniffThemeImageMIME(head, filename) != themeImageMIMEFromName(filename) {
283 http.NotFound(w, r)
284 return
285 }
286 w.Header().Set("Content-Type", themeImageMIMEFromName(filename))
287 w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filename}))
288 w.Header().Set("X-Content-Type-Options", "nosniff")
289 w.Header().Set("Cache-Control", "private, max-age=3600")
290 http.ServeContent(w, r, filename, time.Time{}, bytes.NewReader(data))
291 }
292
293 // errPluginThemeReadOnly is the shared guard message for bound methods that
294 // must never mutate a plugin theme.
295 func errPluginThemeReadOnly(id, op string) error {
296 return fmt.Errorf("plugin theme %q is read-only and cannot be %s; it is managed by its plugin", strings.TrimSpace(id), op)
297 }
298
298 lines GO