返回 DeepSeek-Reasonix
workspace_media.go
根目录 / desktop / workspace_media.go
1 package main
2
3 import (
4 "bytes"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "io"
9 "mime"
10 "net/http"
11 "net/url"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync"
16 "time"
17
18 "golang.org/x/net/html"
19 )
20
21 type mediaDependency struct {
22 absPath string
23 identity os.FileInfo
24 filename string
25 mime string
26 modTime time.Time
27 }
28
29 type mediaTokenEntry struct {
30 absPath string
31 identity os.FileInfo
32 filename string
33 mime string
34 kind string
35 size int64
36 modTime time.Time
37 markdownImage bool
38 createdAt time.Time
39 expiresAt time.Time
40 dependencies map[string]mediaDependency
41 }
42
43 type mediaTokenStore struct {
44 mu sync.Mutex
45 byTok map[string]*mediaTokenEntry
46 order []string
47 maxN int
48 ttl time.Duration
49 }
50
51 const mediaTokenMax = 256
52
53 const (
54 htmlPreviewDependencyMaxBytes = 4 << 20
55 htmlPreviewBundleMaxBytes = 32 << 20
56 htmlPreviewDependencyMaxCount = 64
57 )
58
59 func newMediaTokenStore() *mediaTokenStore {
60 return &mediaTokenStore{
61 byTok: map[string]*mediaTokenEntry{},
62 maxN: mediaTokenMax,
63 ttl: 10 * time.Minute,
64 }
65 }
66
67 func (s *mediaTokenStore) cleanupLocked() {
68 now := time.Now()
69 active := s.order[:0]
70 for _, tok := range s.order {
71 e := s.byTok[tok]
72 if e == nil {
73 continue
74 }
75 if !now.Before(e.expiresAt) {
76 delete(s.byTok, tok)
77 continue
78 }
79 active = append(active, tok)
80 }
81 s.order = active
82 for len(s.order) > s.maxN {
83 oldest := s.order[0]
84 delete(s.byTok, oldest)
85 s.order = s.order[1:]
86 }
87 }
88
89 func (s *mediaTokenStore) extend(token string, ttl time.Duration) bool {
90 s.mu.Lock()
91 defer s.mu.Unlock()
92 s.cleanupLocked()
93 entry := s.byTok[token]
94 if entry == nil {
95 return false
96 }
97 entry.expiresAt = time.Now().Add(ttl)
98 return true
99 }
100
101 func (s *mediaTokenStore) revoke(token string) {
102 s.mu.Lock()
103 defer s.mu.Unlock()
104 delete(s.byTok, token)
105 }
106
107 func (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {
108 return s.createWithPolicy(absPath, filename, mime, kind, size, modTime, false, nil)
109 }
110
111 func (s *mediaTokenStore) createMarkdownImage(absPath, filename, mime string, identity os.FileInfo) string {
112 return s.createWithPolicy(absPath, filename, mime, "image", identity.Size(), identity.ModTime(), true, identity)
113 }
114
115 func (s *mediaTokenStore) createHTML(absPath, allowedRoot, filename, mimeType string, identity os.FileInfo) (string, error) {
116 dependencies, err := collectHTMLPreviewDependencies(absPath, allowedRoot, identity.Size())
117 if err != nil {
118 return "", err
119 }
120 token := s.createWithPolicy(absPath, filename, mimeType, "html", identity.Size(), identity.ModTime(), false, identity)
121 s.mu.Lock()
122 if entry := s.byTok[token]; entry != nil {
123 entry.dependencies = dependencies
124 }
125 s.mu.Unlock()
126 return token, nil
127 }
128
129 func collectHTMLPreviewDependencies(absPath, allowedRoot string, rootSize int64) (map[string]mediaDependency, error) {
130 if rootSize > htmlPreviewBundleMaxBytes {
131 return nil, errors.New("HTML preview exceeds the 32 MiB bundle budget")
132 }
133 f, err := os.Open(absPath)
134 if err != nil {
135 return nil, err
136 }
137 defer f.Close()
138 rootReal, err := filepath.EvalSymlinks(allowedRoot)
139 if err != nil {
140 return nil, err
141 }
142 rootReal, err = filepath.Abs(rootReal)
143 if err != nil {
144 return nil, err
145 }
146 baseDir := filepath.Dir(absPath)
147 dependencies := map[string]mediaDependency{}
148 total := rootSize
149 z := html.NewTokenizer(io.LimitReader(f, htmlPreviewBundleMaxBytes+1))
150 for {
151 tokenType := z.Next()
152 if tokenType == html.ErrorToken {
153 if errors.Is(z.Err(), io.EOF) {
154 break
155 }
156 return nil, z.Err()
157 }
158 if tokenType != html.StartTagToken && tokenType != html.SelfClosingTagToken {
159 continue
160 }
161 tok := z.Token()
162 attrName := ""
163 switch strings.ToLower(tok.Data) {
164 case "script", "img", "source", "audio", "video":
165 attrName = "src"
166 case "link":
167 attrName = "href"
168 default:
169 continue
170 }
171 for _, attr := range tok.Attr {
172 if strings.ToLower(attr.Key) != attrName {
173 continue
174 }
175 ref, parseErr := url.Parse(strings.TrimSpace(attr.Val))
176 if parseErr != nil {
177 return nil, errors.New("HTML preview contains an invalid local resource URL")
178 }
179 if ref.Scheme != "" || ref.Host != "" || ref.Path == "" || strings.HasPrefix(ref.Path, "//") {
180 continue
181 }
182 if strings.HasPrefix(ref.Path, "/") {
183 return nil, errors.New("HTML preview contains an unsupported root-relative local resource: " + ref.Path)
184 }
185 decoded := ref.Path
186 if strings.ContainsRune(decoded, 0) {
187 return nil, errors.New("HTML preview contains an invalid local resource path")
188 }
189 key := filepath.ToSlash(filepath.Clean(filepath.FromSlash(decoded)))
190 if key == "." || key == ".." || strings.HasPrefix(key, "../") || filepath.IsAbs(key) {
191 return nil, errors.New("HTML preview resource leaves the document directory")
192 }
193 if _, exists := dependencies[key]; exists {
194 continue
195 }
196 if len(dependencies) >= htmlPreviewDependencyMaxCount {
197 return nil, errors.New("HTML preview references more than 64 local resources")
198 }
199 candidate := filepath.Join(baseDir, filepath.FromSlash(key))
200 finalInfo, lstatErr := os.Lstat(candidate)
201 if lstatErr != nil {
202 return nil, errors.New("HTML preview resource is missing: " + key)
203 }
204 if finalInfo.Mode()&os.ModeSymlink != 0 || !finalInfo.Mode().IsRegular() {
205 return nil, errors.New("HTML preview resource is not a regular file: " + key)
206 }
207 candidateReal, evalErr := filepath.EvalSymlinks(candidate)
208 if evalErr != nil {
209 return nil, evalErr
210 }
211 candidateReal, evalErr = filepath.Abs(candidateReal)
212 if evalErr != nil {
213 return nil, evalErr
214 }
215 relative, relErr := filepath.Rel(rootReal, candidateReal)
216 if relErr != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
217 return nil, errors.New("HTML preview resource leaves the authorized root: " + key)
218 }
219 if finalInfo.Size() > htmlPreviewDependencyMaxBytes {
220 return nil, errors.New("HTML preview resource exceeds 4 MiB: " + key)
221 }
222 total += finalInfo.Size()
223 if total > htmlPreviewBundleMaxBytes {
224 return nil, errors.New("HTML preview bundle exceeds 32 MiB")
225 }
226 depMIME := mime.TypeByExtension(strings.ToLower(filepath.Ext(candidateReal)))
227 if depMIME == "" {
228 depMIME = "application/octet-stream"
229 }
230 dependencies[key] = mediaDependency{absPath: candidateReal, identity: finalInfo, filename: filepath.Base(candidateReal), mime: depMIME, modTime: finalInfo.ModTime()}
231 }
232 }
233 return dependencies, nil
234 }
235
236 func (s *mediaTokenStore) createWithPolicy(absPath, filename, mime, kind string, size int64, modTime time.Time, markdownImage bool, identity os.FileInfo) string {
237 s.mu.Lock()
238 defer s.mu.Unlock()
239 s.cleanupLocked()
240
241 tok := make([]byte, 16)
242 if _, err := rand.Read(tok); err != nil {
243 panic("crypto/rand.Read failed: " + err.Error())
244 }
245 token := hex.EncodeToString(tok)
246 now := time.Now()
247 if identity == nil {
248 identity, _ = os.Stat(absPath)
249 }
250 s.byTok[token] = &mediaTokenEntry{
251 absPath: absPath, identity: identity, filename: filename, mime: mime, kind: kind,
252 size: size, modTime: modTime, markdownImage: markdownImage, createdAt: now, expiresAt: now.Add(s.ttl),
253 }
254 s.order = append(s.order, token)
255
256 for len(s.order) > s.maxN {
257 oldest := s.order[0]
258 delete(s.byTok, oldest)
259 s.order = s.order[1:]
260 }
261 return token
262 }
263
264 func readValidatedMarkdownImageSnapshot(f *os.File, mimeType string, size int64) ([]byte, error) {
265 if size <= 0 {
266 return nil, errors.New("empty markdown image")
267 }
268 if size > remoteMarkdownImageMaxBytes {
269 return nil, errMarkdownImageTooLarge
270 }
271 body, err := io.ReadAll(io.LimitReader(f, remoteMarkdownImageMaxBytes+1))
272 if err != nil {
273 return nil, err
274 }
275 if len(body) > remoteMarkdownImageMaxBytes {
276 return nil, errMarkdownImageTooLarge
277 }
278 snapshot, detected := safeRemoteMarkdownImage(body)
279 if detected == "" || detected != mimeType {
280 return nil, errors.New("markdown image MIME does not match the authorized file")
281 }
282 if err := validateMarkdownImageBytes(snapshot, detected); err != nil {
283 return nil, err
284 }
285 return snapshot, nil
286 }
287
288 func (s *mediaTokenStore) get(token string) *mediaTokenEntry {
289 s.mu.Lock()
290 defer s.mu.Unlock()
291 e := s.byTok[token]
292 if e == nil {
293 return nil
294 }
295 if time.Now().After(e.expiresAt) {
296 delete(s.byTok, token)
297 return nil
298 }
299 return e
300 }
301
302 func (a *App) ensureMediaTokenStore() *mediaTokenStore {
303 a.mu.Lock()
304 defer a.mu.Unlock()
305 if a.mediaTokens == nil {
306 a.mediaTokens = newMediaTokenStore()
307 }
308 return a.mediaTokens
309 }
310
311 // workspaceMediaMiddleware serves only files whose identity still matches the
312 // regular file authorized when the short-lived token was minted.
313 func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {
314 return func(next http.Handler) http.Handler {
315 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
316 const prefix = "/__reasonix_workspace_media/"
317 if !strings.HasPrefix(r.URL.Path, prefix) {
318 next.ServeHTTP(w, r)
319 return
320 }
321 if r.Method != http.MethodGet && r.Method != http.MethodHead {
322 w.WriteHeader(http.StatusMethodNotAllowed)
323 return
324 }
325
326 parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, prefix), "/", 2)
327 if len(parts) == 0 || parts[0] == "" {
328 http.NotFound(w, r)
329 return
330 }
331 entry := a.ensureMediaTokenStore().get(parts[0])
332 if entry == nil {
333 http.NotFound(w, r)
334 return
335 }
336
337 path := entry.absPath
338 identity := entry.identity
339 filename := entry.filename
340 mimeType := entry.mime
341 modTime := entry.modTime
342 isRoot := true
343 if len(parts) == 2 {
344 requested := parts[1]
345 if strings.ContainsRune(requested, 0) {
346 http.NotFound(w, r)
347 return
348 }
349 requested = filepath.ToSlash(filepath.Clean(filepath.FromSlash(requested)))
350 if requested != entry.filename {
351 dependency, found := entry.dependencies[requested]
352 if !found {
353 http.NotFound(w, r)
354 return
355 }
356 path, identity, filename, mimeType, modTime = dependency.absPath, dependency.identity, dependency.filename, dependency.mime, dependency.modTime
357 isRoot = false
358 }
359 }
360
361 f, err := os.Open(path)
362 if err != nil {
363 http.NotFound(w, r)
364 return
365 }
366 defer f.Close()
367 opened, err := f.Stat()
368 if err != nil || identity == nil || !opened.Mode().IsRegular() || !os.SameFile(identity, opened) {
369 http.NotFound(w, r)
370 return
371 }
372 content := io.ReadSeeker(f)
373 if entry.markdownImage {
374 snapshot, snapshotErr := readValidatedMarkdownImageSnapshot(f, entry.mime, opened.Size())
375 if snapshotErr != nil {
376 if errors.Is(snapshotErr, errMarkdownImageTooLarge) {
377 http.Error(w, "markdown image exceeds the decode budget", http.StatusRequestEntityTooLarge)
378 } else {
379 http.Error(w, "markdown image is invalid", http.StatusUnsupportedMediaType)
380 }
381 return
382 }
383 content = bytes.NewReader(snapshot)
384 }
385
386 w.Header().Set("Content-Type", mimeType)
387 w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filename}))
388 w.Header().Set("X-Content-Type-Options", "nosniff")
389 w.Header().Set("Cache-Control", "private, max-age=600")
390 if entry.kind == "image" {
391 w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
392 w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
393 w.Header().Set("Referrer-Policy", "no-referrer")
394 } else if entry.kind == "html" && isRoot {
395 w.Header().Set("Content-Security-Policy", "default-src 'self' https: data: blob:; script-src 'self' https: 'unsafe-inline'; style-src 'self' https: 'unsafe-inline'; img-src 'self' https: data: blob:; media-src 'self' https: data: blob:; connect-src https:; frame-src https:; object-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts")
396 w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
397 w.Header().Set("Referrer-Policy", "no-referrer")
398 }
399 http.ServeContent(w, r, filename, modTime, content)
400 })
401 }
402 }
403
403 lines GO