返回 DeepSeek-Reasonix
attachments.go
根目录 / internal / control / attachments.go
1 package control
2
3 import (
4 "encoding/base64"
5 "fmt"
6 "io"
7 "net/http"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "regexp"
12 "runtime"
13 "strings"
14 "sync/atomic"
15 "time"
16
17 "reasonix/internal/proc"
18 "reasonix/internal/secrets"
19 )
20
21 const maxImageAttachmentBytes = 10 * 1024 * 1024
22 const maxFileAttachmentBytes = 25 * 1024 * 1024
23 const maxAttachmentCreateAttempts = 1000
24
25 var attachmentPathSeq atomic.Uint64
26 var attachmentNow = time.Now
27 var safeAttachmentExt = regexp.MustCompile(`^\.[a-z0-9]{1,12}$`)
28
29 // SaveAttachmentDataURL stores a non-image file (dropped/pasted in the desktop
30 // app, where the browser exposes bytes but not a real path) under
31 // .reasonix/attachments and returns its repo-relative path for @referencing.
32 // origName supplies only the extension; the stored name is generated.
33 func SaveAttachmentDataURL(origName, dataURL string) (string, error) {
34 const marker = ";base64,"
35 i := strings.Index(dataURL, marker)
36 if !strings.HasPrefix(dataURL, "data:") || i < 0 {
37 return "", fmt.Errorf("unsupported pasted file")
38 }
39 raw, err := base64.StdEncoding.DecodeString(dataURL[i+len(marker):])
40 if err != nil {
41 return "", fmt.Errorf("decode pasted file: %w", err)
42 }
43 return SaveAttachmentBytes(origName, raw)
44 }
45
46 func SaveAttachmentBytes(origName string, raw []byte) (string, error) {
47 return SaveAttachmentBytesInRoot(".", origName, raw)
48 }
49
50 func SaveAttachmentBytesInRoot(root, origName string, raw []byte) (string, error) {
51 if len(raw) == 0 || len(raw) > maxFileAttachmentBytes {
52 return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
53 }
54 ext := strings.ToLower(filepath.Ext(origName))
55 if !safeAttachmentExt.MatchString(ext) {
56 ext = ".bin"
57 }
58 return saveAttachmentBytesInRoot(root, ext, raw)
59 }
60
61 func SaveImageDataURL(dataURL string) (string, error) {
62 const prefix = "data:"
63 const marker = ";base64,"
64 if !strings.HasPrefix(dataURL, prefix) {
65 return "", fmt.Errorf("unsupported pasted image")
66 }
67 i := strings.Index(dataURL, marker)
68 if i <= len(prefix) {
69 return "", fmt.Errorf("unsupported pasted image")
70 }
71 mime := strings.ToLower(dataURL[len(prefix):i])
72 raw, err := base64.StdEncoding.DecodeString(dataURL[i+len(marker):])
73 if err != nil {
74 return "", fmt.Errorf("decode pasted image: %w", err)
75 }
76 return SaveImageBytes(mime, raw)
77 }
78
79 func SaveImageBytes(declaredMime string, raw []byte) (string, error) {
80 return SaveImageBytesInRoot(".", declaredMime, raw)
81 }
82
83 func SaveImageBytesInRoot(root, declaredMime string, raw []byte) (string, error) {
84 if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
85 return "", fmt.Errorf("pasted image must be between 1 byte and 10 MB")
86 }
87 mime := detectedImageMime(raw)
88 if mime == "" {
89 return "", fmt.Errorf("pasted data is not a supported image")
90 }
91 if declaredMime != "" && imageExt(declaredMime) == "" {
92 return "", fmt.Errorf("unsupported image type: %s", declaredMime)
93 }
94 ext := imageExt(mime)
95 return saveAttachmentBytesInRoot(root, ext, raw)
96 }
97
98 func saveAttachmentBytesInRoot(root, ext string, raw []byte) (string, error) {
99 if strings.TrimSpace(root) == "" {
100 root = "."
101 }
102 absRoot, err := filepath.Abs(root)
103 if err != nil {
104 return "", err
105 }
106 if err := ensureAttachmentRootIn(absRoot); err != nil {
107 return "", err
108 }
109 rel, f, err := createAttachmentFileIn(absRoot, ext)
110 if err != nil {
111 return "", err
112 }
113 if n, err := f.Write(raw); err != nil {
114 _ = f.Close()
115 _ = os.Remove(filepath.Join(absRoot, rel))
116 return "", err
117 } else if n != len(raw) {
118 _ = f.Close()
119 _ = os.Remove(filepath.Join(absRoot, rel))
120 return "", io.ErrShortWrite
121 }
122 if err := f.Close(); err != nil {
123 _ = os.Remove(filepath.Join(absRoot, rel))
124 return "", err
125 }
126 return filepath.ToSlash(rel), nil
127 }
128
129 func SaveImageFile(path string) (string, error) {
130 info, err := os.Lstat(path)
131 if err != nil {
132 return "", err
133 }
134 if info.Mode()&os.ModeSymlink != 0 {
135 return "", fmt.Errorf("pasted image path must not be a symlink")
136 }
137 if info.IsDir() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
138 return "", fmt.Errorf("pasted image must be between 1 byte and 10 MB")
139 }
140 f, err := os.Open(path)
141 if err != nil {
142 return "", err
143 }
144 defer f.Close()
145 opened, err := f.Stat()
146 if err != nil {
147 return "", err
148 }
149 if !os.SameFile(info, opened) {
150 return "", fmt.Errorf("pasted image changed while opening")
151 }
152 raw, err := io.ReadAll(io.LimitReader(f, maxImageAttachmentBytes+1))
153 if err != nil {
154 return "", err
155 }
156 if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
157 return "", fmt.Errorf("pasted image must be between 1 byte and 10 MB")
158 }
159 if after, err := f.Stat(); err != nil {
160 return "", err
161 } else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
162 return "", fmt.Errorf("pasted image changed while reading")
163 }
164 return SaveImageBytes("", raw)
165 }
166
167 func SaveAttachmentFile(path string) (string, error) {
168 info, err := os.Lstat(path)
169 if err != nil {
170 return "", err
171 }
172 if info.Mode()&os.ModeSymlink != 0 {
173 return "", fmt.Errorf("attachment path must not be a symlink")
174 }
175 if info.IsDir() || info.Size() <= 0 || info.Size() > maxFileAttachmentBytes {
176 return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
177 }
178 f, err := os.Open(path)
179 if err != nil {
180 return "", err
181 }
182 defer f.Close()
183 opened, err := f.Stat()
184 if err != nil {
185 return "", err
186 }
187 if !os.SameFile(info, opened) {
188 return "", fmt.Errorf("attachment changed while opening")
189 }
190 raw, err := io.ReadAll(io.LimitReader(f, maxFileAttachmentBytes+1))
191 if err != nil {
192 return "", err
193 }
194 if len(raw) == 0 || len(raw) > maxFileAttachmentBytes {
195 return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
196 }
197 if after, err := f.Stat(); err != nil {
198 return "", err
199 } else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
200 return "", fmt.Errorf("attachment changed while reading")
201 }
202 ext := strings.ToLower(filepath.Ext(path))
203 if !safeAttachmentExt.MatchString(ext) {
204 ext = ".bin"
205 }
206 if err := ensureAttachmentRoot(); err != nil {
207 return "", err
208 }
209 rel, dst, err := createAttachmentFile(ext)
210 if err != nil {
211 return "", err
212 }
213 if _, err := dst.Write(raw); err != nil {
214 _ = dst.Close()
215 _ = os.Remove(rel)
216 return "", err
217 }
218 if err := dst.Close(); err != nil {
219 _ = os.Remove(rel)
220 return "", err
221 }
222 return filepath.ToSlash(rel), nil
223 }
224
225 func SaveClipboardImage() (string, error) {
226 switch runtime.GOOS {
227 case "darwin":
228 return saveDarwinClipboardImage()
229 case "windows":
230 return saveWindowsClipboardImage()
231 case "linux":
232 return saveLinuxClipboardImage()
233 default:
234 return "", fmt.Errorf("clipboard image paste is not supported on %s yet", runtime.GOOS)
235 }
236 }
237
238 func saveWindowsClipboardImage() (string, error) {
239 // Windows PowerShell 5.1 (preinstalled) reaches the GUI clipboard; pwsh (Core)
240 // lacks Get-Clipboard -Format Image, so invoke powershell.exe. The PNG is
241 // returned as base64 on stdout so no temp file is involved.
242 script := `Add-Type -AssemblyName System.Drawing
243 $img = Get-Clipboard -Format Image
244 if ($null -eq $img) { [Console]::Error.WriteLine('clipboard has no image'); exit 1 }
245 $ms = New-Object System.IO.MemoryStream
246 $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
247 [Convert]::ToBase64String($ms.ToArray())`
248 cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
249 cmd.Env = secrets.ProcessEnv()
250 proc.HideWindow(cmd)
251 out, err := cmd.Output()
252 if err != nil {
253 if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
254 return "", fmt.Errorf("read clipboard image: %s", strings.TrimSpace(string(ee.Stderr)))
255 }
256 return "", fmt.Errorf("read clipboard image: %w", err)
257 }
258 raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(out)))
259 if err != nil {
260 return "", fmt.Errorf("decode clipboard image: %w", err)
261 }
262 return SaveImageBytes("", raw)
263 }
264
265 func saveLinuxClipboardImage() (string, error) {
266 // Wayland (wl-paste) then X11 (xclip); both write image bytes to stdout.
267 for _, c := range [][]string{
268 {"wl-paste", "--type", "image/png", "--no-newline"},
269 {"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
270 } {
271 cmd := exec.Command(c[0], c[1:]...)
272 cmd.Env = secrets.ProcessEnv()
273 if out, err := cmd.Output(); err == nil && len(out) > 0 {
274 return SaveImageBytes("", out)
275 }
276 }
277 return "", fmt.Errorf("clipboard image paste needs wl-paste (Wayland) or xclip (X11)")
278 }
279
280 func ImageDataURL(path string) (string, error) {
281 raw, mime, err := readAttachmentImage(path)
282 if err != nil {
283 return "", err
284 }
285 return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
286 }
287
288 // visionImageDataURL reads an attachment and, unlike ImageDataURL (which feeds
289 // the desktop preview at full resolution), downscales/recompresses it before
290 // base64 so an oversized photo doesn't balloon the request bytes and image
291 // tokens. Best-effort: an undecodable format passes through at original size.
292 func visionImageDataURL(path string) (string, error) {
293 raw, mime, err := readAttachmentImage(path)
294 if err != nil {
295 return "", err
296 }
297 raw, mime = compressForVision(raw, mime)
298 return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
299 }
300
301 func readAttachmentImage(path string) (raw []byte, mime string, err error) {
302 clean, err := cleanAttachmentPath(path)
303 if err != nil {
304 return nil, "", err
305 }
306 info, err := os.Lstat(clean)
307 if err != nil {
308 return nil, "", err
309 }
310 if info.Mode()&os.ModeSymlink != 0 {
311 return nil, "", fmt.Errorf("attachment path must not be a symlink")
312 }
313 if info.IsDir() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
314 return nil, "", fmt.Errorf("attachment image must be between 1 byte and 10 MB")
315 }
316 f, err := os.Open(clean)
317 if err != nil {
318 return nil, "", err
319 }
320 defer f.Close()
321 opened, err := f.Stat()
322 if err != nil {
323 return nil, "", err
324 }
325 if !os.SameFile(info, opened) {
326 return nil, "", fmt.Errorf("attachment changed while opening")
327 }
328 raw, err = io.ReadAll(io.LimitReader(f, maxImageAttachmentBytes+1))
329 if err != nil {
330 return nil, "", err
331 }
332 if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
333 return nil, "", fmt.Errorf("attachment image must be between 1 byte and 10 MB")
334 }
335 if after, err := f.Stat(); err != nil {
336 return nil, "", err
337 } else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
338 return nil, "", fmt.Errorf("attachment changed while reading")
339 }
340 mime = detectedImageMime(raw)
341 if mime == "" {
342 return nil, "", fmt.Errorf("attachment is not an image")
343 }
344 return raw, mime, nil
345 }
346
347 func cleanAttachmentPath(path string) (string, error) {
348 if filepath.IsAbs(path) {
349 return "", fmt.Errorf("attachment path must be relative")
350 }
351 clean := filepath.Clean(filepath.FromSlash(path))
352 root := filepath.Join(".reasonix", "attachments")
353 if clean == "." || clean == root || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || !strings.HasPrefix(clean, root+string(filepath.Separator)) {
354 return "", fmt.Errorf("attachment path is outside .reasonix/attachments")
355 }
356 if err := ensureAttachmentRoot(); err != nil {
357 return "", err
358 }
359 if err := rejectSymlinkComponents(clean, root); err != nil {
360 return "", err
361 }
362 return clean, nil
363 }
364
365 func rejectSymlinkComponents(path, root string) error {
366 rel, err := filepath.Rel(root, path)
367 if err != nil {
368 return err
369 }
370 if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
371 return fmt.Errorf("attachment path is outside .reasonix/attachments")
372 }
373 cur := root
374 for _, part := range strings.Split(rel, string(filepath.Separator)) {
375 if part == "" || part == "." {
376 continue
377 }
378 cur = filepath.Join(cur, part)
379 info, err := os.Lstat(cur)
380 if err != nil {
381 return err
382 }
383 if info.Mode()&os.ModeSymlink != 0 {
384 return fmt.Errorf("attachment path must not contain symlinks")
385 }
386 }
387 return nil
388 }
389
390 func ensureAttachmentRoot() error {
391 return ensureAttachmentRootIn(".")
392 }
393
394 func ensureAttachmentRootIn(base string) error {
395 root := filepath.Join(base, ".reasonix", "attachments")
396 if info, err := os.Lstat(root); err == nil {
397 if info.Mode()&os.ModeSymlink != 0 {
398 return fmt.Errorf("attachment directory must not be a symlink")
399 }
400 if !info.IsDir() {
401 return fmt.Errorf("attachment path exists but is not a directory")
402 }
403 return nil
404 } else if !os.IsNotExist(err) {
405 return err
406 }
407 if err := os.MkdirAll(root, 0o755); err != nil {
408 return err
409 }
410 info, err := os.Lstat(root)
411 if err != nil {
412 return err
413 }
414 if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
415 return fmt.Errorf("attachment directory is invalid")
416 }
417 return nil
418 }
419
420 func saveDarwinClipboardImage() (string, error) {
421 for _, class := range []string{"PNGf", "JPEG"} {
422 if rel, err := saveDarwinClipboardClass(class); err == nil {
423 return rel, nil
424 }
425 }
426 return "", fmt.Errorf("clipboard does not contain a supported image")
427 }
428
429 func saveDarwinClipboardClass(class string) (string, error) {
430 if err := ensureAttachmentRoot(); err != nil {
431 return "", err
432 }
433 rel, f, err := createAttachmentFile(".bin")
434 if err != nil {
435 return "", err
436 }
437 if err := f.Close(); err != nil {
438 _ = os.Remove(rel)
439 return "", err
440 }
441 abs, err := filepath.Abs(rel)
442 if err != nil {
443 _ = os.Remove(rel)
444 return "", err
445 }
446 script := fmt.Sprintf(`
447 set outPath to POSIX file %q
448 try
449 set img to the clipboard as «class %s»
450 on error
451 error "clipboard does not contain this image type"
452 end try
453 set f to open for access outPath with write permission
454 try
455 set eof f to 0
456 write img to f
457 close access f
458 on error errMsg
459 try
460 close access f
461 end try
462 error errMsg
463 end try
464 `, abs, class)
465 clip := exec.Command("osascript", "-e", script)
466 clip.Env = secrets.ProcessEnv()
467 if out, err := clip.CombinedOutput(); err != nil {
468 _ = os.Remove(rel)
469 return "", fmt.Errorf("read clipboard image: %s", strings.TrimSpace(string(out)))
470 }
471 raw, err := os.ReadFile(rel)
472 _ = os.Remove(rel)
473 if err != nil {
474 return "", err
475 }
476 return SaveImageBytes("", raw)
477 }
478
479 func createAttachmentFile(ext string) (string, *os.File, error) {
480 return createAttachmentFileIn(".", ext)
481 }
482
483 func createAttachmentFileIn(base, ext string) (string, *os.File, error) {
484 for range maxAttachmentCreateAttempts {
485 rel := attachmentPath(ext)
486 f, err := os.OpenFile(filepath.Join(base, rel), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
487 if os.IsExist(err) {
488 continue
489 }
490 if err != nil {
491 return "", nil, err
492 }
493 return rel, f, nil
494 }
495 return "", nil, fmt.Errorf("create unique attachment path")
496 }
497
498 func attachmentPath(ext string) string {
499 seq := attachmentPathSeq.Add(1)
500 name := fmt.Sprintf("clipboard-%s-%06d%s", attachmentNow().Format("20060102-150405.000000"), seq, ext)
501 return filepath.Join(".reasonix", "attachments", name)
502 }
503
504 func detectedImageMime(raw []byte) string {
505 if len(raw) == 0 {
506 return ""
507 }
508 mime := http.DetectContentType(raw[:min(len(raw), 512)])
509 if imageExt(mime) == "" {
510 return ""
511 }
512 return mime
513 }
514
515 func imageExt(mime string) string {
516 switch strings.ToLower(strings.TrimSpace(mime)) {
517 case "image/png":
518 return ".png"
519 case "image/jpeg":
520 return ".jpg"
521 case "image/gif":
522 return ".gif"
523 case "image/webp":
524 return ".webp"
525 }
526 return ""
527 }
528
528 lines GO