返回 DeepSeek-Reasonix
theme_assets.go
根目录 / desktop / theme_assets.go
1 package main
2
3 import (
4 "bytes"
5 "mime"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11 )
12
13 const themeAssetURLPrefix = "/__reasonix_theme_asset/"
14
15 // themeAssetMiddleware serves theme background images through a content-addressed
16 // read-only route. The frontend only receives temporary URLs — never absolute paths.
17 //
18 // URL shape: /__reasonix_theme_asset/{themeID}/{digest}/{filename}
19 // On every request the server re-validates theme id, filename, MIME, file identity
20 // (digest), and directory containment.
21 func (a *App) themeAssetMiddleware() func(http.Handler) http.Handler {
22 return func(next http.Handler) http.Handler {
23 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24 if !strings.HasPrefix(r.URL.Path, themeAssetURLPrefix) {
25 next.ServeHTTP(w, r)
26 return
27 }
28 if r.Method != http.MethodGet && r.Method != http.MethodHead {
29 w.WriteHeader(http.StatusMethodNotAllowed)
30 return
31 }
32 rest := strings.TrimPrefix(r.URL.Path, themeAssetURLPrefix)
33 parts := strings.Split(rest, "/")
34 if len(parts) != 3 {
35 http.NotFound(w, r)
36 return
37 }
38 themeID, digest, filename := parts[0], parts[1], parts[2]
39 // Plugin themes (plugin:<plugin>:<theme>) serve scene images
40 // straight from the installed plugin's ZIP; everything else keeps
41 // the registry/library path below.
42 if pluginName, pluginThemeID, ok := parsePluginThemeID(themeID); ok {
43 servePluginThemeAsset(w, r, pluginName, pluginThemeID, digest, filename)
44 return
45 }
46 if !themePackIDRe.MatchString(themeID) {
47 http.NotFound(w, r)
48 return
49 }
50 if !themePackImageRe.MatchString(filename) {
51 http.NotFound(w, r)
52 return
53 }
54 if len(digest) < 8 || len(digest) > 64 {
55 http.NotFound(w, r)
56 return
57 }
58 // Official themes serve embedded assets with immutable caching;
59 // everything else falls through to the user library path below.
60 if isOfficialThemeID(themeID) {
61 serveOfficialThemeAsset(w, r, themeID, digest, filename)
62 return
63 }
64 if isBuiltinThemeID(themeID) {
65 http.NotFound(w, r)
66 return
67 }
68 abs, err := resolveThemeImageAbs(themeID, filename)
69 if err != nil {
70 http.NotFound(w, r)
71 return
72 }
73 // Re-validate file identity against the digest embedded in the URL.
74 got, err := themeFileDigest(abs)
75 if err != nil || !strings.EqualFold(got, digest) {
76 http.NotFound(w, r)
77 return
78 }
79 if err := validateThemeImageFile(abs); err != nil {
80 http.NotFound(w, r)
81 return
82 }
83 f, err := os.Open(abs)
84 if err != nil {
85 http.NotFound(w, r)
86 return
87 }
88 defer f.Close()
89 info, err := f.Stat()
90 if err != nil {
91 http.NotFound(w, r)
92 return
93 }
94 mimeType := themeImageMIMEFromName(filename)
95 w.Header().Set("Content-Type", mimeType)
96 w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filename}))
97 w.Header().Set("X-Content-Type-Options", "nosniff")
98 w.Header().Set("Cache-Control", "private, max-age=3600")
99 http.ServeContent(w, r, filename, info.ModTime(), f)
100 })
101 }
102 }
103
104 // serveOfficialThemeAsset serves embedded official backgrounds and previews.
105 // The URL digest is re-verified against the embedded bytes on every request;
106 // only the manifest-declared background and the fixed preview name resolve.
107 func serveOfficialThemeAsset(w http.ResponseWriter, r *http.Request, themeID, digest, filename string) {
108 ot := findOfficialTheme(themeID)
109 if ot == nil {
110 http.NotFound(w, r)
111 return
112 }
113 want := ""
114 switch filename {
115 case ot.manifest.Background.Image:
116 want = ot.bgDigest
117 case officialPreviewName:
118 want = ot.previewDigest
119 default:
120 http.NotFound(w, r)
121 return
122 }
123 if !strings.EqualFold(want, digest) {
124 http.NotFound(w, r)
125 return
126 }
127 data, name, err := readOfficialAsset(themeID, filename)
128 if err != nil {
129 http.NotFound(w, r)
130 return
131 }
132 // MIME re-verification: sniff must agree with the declared extension.
133 head := data
134 if len(head) > 512 {
135 head = head[:512]
136 }
137 if sniffThemeImageMIME(head, name) != themeImageMIMEFromName(name) {
138 http.NotFound(w, r)
139 return
140 }
141 w.Header().Set("Content-Type", themeImageMIMEFromName(name))
142 w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": name}))
143 w.Header().Set("X-Content-Type-Options", "nosniff")
144 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
145 http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
146 }
147
148 func themeBackgroundURL(themeID, imageName string) string {
149 if imageName == "" || isBuiltinThemeID(themeID) {
150 return ""
151 }
152 abs, err := resolveThemeImageAbs(themeID, imageName)
153 if err != nil {
154 return ""
155 }
156 digest, err := themeFileDigest(abs)
157 if err != nil {
158 return ""
159 }
160 return themeAssetURLPrefix + themeID + "/" + digest + "/" + filepath.Base(imageName)
161 }
162
162 lines GO