528 lines
15 KiB
Go
528 lines
15 KiB
Go
package control
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"reasonix/internal/proc"
|
|
"reasonix/internal/secrets"
|
|
)
|
|
|
|
const maxImageAttachmentBytes = 10 * 1024 * 1024
|
|
const maxFileAttachmentBytes = 25 * 1024 * 1024
|
|
const maxAttachmentCreateAttempts = 1000
|
|
|
|
var attachmentPathSeq atomic.Uint64
|
|
var attachmentNow = time.Now
|
|
var safeAttachmentExt = regexp.MustCompile(`^\.[a-z0-9]{1,12}$`)
|
|
|
|
// SaveAttachmentDataURL stores a non-image file (dropped/pasted in the desktop
|
|
// app, where the browser exposes bytes but not a real path) under
|
|
// .reasonix/attachments and returns its repo-relative path for @referencing.
|
|
// origName supplies only the extension; the stored name is generated.
|
|
func SaveAttachmentDataURL(origName, dataURL string) (string, error) {
|
|
const marker = ";base64,"
|
|
i := strings.Index(dataURL, marker)
|
|
if !strings.HasPrefix(dataURL, "data:") || i < 0 {
|
|
return "", fmt.Errorf("unsupported pasted file")
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(dataURL[i+len(marker):])
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode pasted file: %w", err)
|
|
}
|
|
return SaveAttachmentBytes(origName, raw)
|
|
}
|
|
|
|
func SaveAttachmentBytes(origName string, raw []byte) (string, error) {
|
|
return SaveAttachmentBytesInRoot(".", origName, raw)
|
|
}
|
|
|
|
func SaveAttachmentBytesInRoot(root, origName string, raw []byte) (string, error) {
|
|
if len(raw) == 0 || len(raw) > maxFileAttachmentBytes {
|
|
return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(origName))
|
|
if !safeAttachmentExt.MatchString(ext) {
|
|
ext = ".bin"
|
|
}
|
|
return saveAttachmentBytesInRoot(root, ext, raw)
|
|
}
|
|
|
|
func SaveImageDataURL(dataURL string) (string, error) {
|
|
const prefix = "data:"
|
|
const marker = ";base64,"
|
|
if !strings.HasPrefix(dataURL, prefix) {
|
|
return "", fmt.Errorf("unsupported pasted image")
|
|
}
|
|
i := strings.Index(dataURL, marker)
|
|
if i <= len(prefix) {
|
|
return "", fmt.Errorf("unsupported pasted image")
|
|
}
|
|
mime := strings.ToLower(dataURL[len(prefix):i])
|
|
raw, err := base64.StdEncoding.DecodeString(dataURL[i+len(marker):])
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode pasted image: %w", err)
|
|
}
|
|
return SaveImageBytes(mime, raw)
|
|
}
|
|
|
|
func SaveImageBytes(declaredMime string, raw []byte) (string, error) {
|
|
return SaveImageBytesInRoot(".", declaredMime, raw)
|
|
}
|
|
|
|
func SaveImageBytesInRoot(root, declaredMime string, raw []byte) (string, error) {
|
|
if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
|
|
return "", fmt.Errorf("pasted image must be between 1 byte and 10 MB")
|
|
}
|
|
mime := detectedImageMime(raw)
|
|
if mime == "" {
|
|
return "", fmt.Errorf("pasted data is not a supported image")
|
|
}
|
|
if declaredMime != "" && imageExt(declaredMime) == "" {
|
|
return "", fmt.Errorf("unsupported image type: %s", declaredMime)
|
|
}
|
|
ext := imageExt(mime)
|
|
return saveAttachmentBytesInRoot(root, ext, raw)
|
|
}
|
|
|
|
func saveAttachmentBytesInRoot(root, ext string, raw []byte) (string, error) {
|
|
if strings.TrimSpace(root) == "" {
|
|
root = "."
|
|
}
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := ensureAttachmentRootIn(absRoot); err != nil {
|
|
return "", err
|
|
}
|
|
rel, f, err := createAttachmentFileIn(absRoot, ext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if n, err := f.Write(raw); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(filepath.Join(absRoot, rel))
|
|
return "", err
|
|
} else if n != len(raw) {
|
|
_ = f.Close()
|
|
_ = os.Remove(filepath.Join(absRoot, rel))
|
|
return "", io.ErrShortWrite
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
_ = os.Remove(filepath.Join(absRoot, rel))
|
|
return "", err
|
|
}
|
|
return filepath.ToSlash(rel), nil
|
|
}
|
|
|
|
func SaveImageFile(path string) (string, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", fmt.Errorf("pasted image path must not be a symlink")
|
|
}
|
|
if info.IsDir() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
|
|
return "", fmt.Errorf("pasted image must be between 1 byte and 10 MB")
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
opened, err := f.Stat()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !os.SameFile(info, opened) {
|
|
return "", fmt.Errorf("pasted image changed while opening")
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(f, maxImageAttachmentBytes+1))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
|
|
return "", fmt.Errorf("pasted image must be between 1 byte and 10 MB")
|
|
}
|
|
if after, err := f.Stat(); err != nil {
|
|
return "", err
|
|
} else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
|
|
return "", fmt.Errorf("pasted image changed while reading")
|
|
}
|
|
return SaveImageBytes("", raw)
|
|
}
|
|
|
|
func SaveAttachmentFile(path string) (string, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", fmt.Errorf("attachment path must not be a symlink")
|
|
}
|
|
if info.IsDir() || info.Size() <= 0 || info.Size() > maxFileAttachmentBytes {
|
|
return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
opened, err := f.Stat()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !os.SameFile(info, opened) {
|
|
return "", fmt.Errorf("attachment changed while opening")
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(f, maxFileAttachmentBytes+1))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(raw) == 0 || len(raw) > maxFileAttachmentBytes {
|
|
return "", fmt.Errorf("attachment must be between 1 byte and 25 MB")
|
|
}
|
|
if after, err := f.Stat(); err != nil {
|
|
return "", err
|
|
} else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
|
|
return "", fmt.Errorf("attachment changed while reading")
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if !safeAttachmentExt.MatchString(ext) {
|
|
ext = ".bin"
|
|
}
|
|
if err := ensureAttachmentRoot(); err != nil {
|
|
return "", err
|
|
}
|
|
rel, dst, err := createAttachmentFile(ext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := dst.Write(raw); err != nil {
|
|
_ = dst.Close()
|
|
_ = os.Remove(rel)
|
|
return "", err
|
|
}
|
|
if err := dst.Close(); err != nil {
|
|
_ = os.Remove(rel)
|
|
return "", err
|
|
}
|
|
return filepath.ToSlash(rel), nil
|
|
}
|
|
|
|
func SaveClipboardImage() (string, error) {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return saveDarwinClipboardImage()
|
|
case "windows":
|
|
return saveWindowsClipboardImage()
|
|
case "linux":
|
|
return saveLinuxClipboardImage()
|
|
default:
|
|
return "", fmt.Errorf("clipboard image paste is not supported on %s yet", runtime.GOOS)
|
|
}
|
|
}
|
|
|
|
func saveWindowsClipboardImage() (string, error) {
|
|
// Windows PowerShell 5.1 (preinstalled) reaches the GUI clipboard; pwsh (Core)
|
|
// lacks Get-Clipboard -Format Image, so invoke powershell.exe. The PNG is
|
|
// returned as base64 on stdout so no temp file is involved.
|
|
script := `Add-Type -AssemblyName System.Drawing
|
|
$img = Get-Clipboard -Format Image
|
|
if ($null -eq $img) { [Console]::Error.WriteLine('clipboard has no image'); exit 1 }
|
|
$ms = New-Object System.IO.MemoryStream
|
|
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
[Convert]::ToBase64String($ms.ToArray())`
|
|
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
|
|
cmd.Env = secrets.ProcessEnv()
|
|
proc.HideWindow(cmd)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
|
return "", fmt.Errorf("read clipboard image: %s", strings.TrimSpace(string(ee.Stderr)))
|
|
}
|
|
return "", fmt.Errorf("read clipboard image: %w", err)
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(out)))
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode clipboard image: %w", err)
|
|
}
|
|
return SaveImageBytes("", raw)
|
|
}
|
|
|
|
func saveLinuxClipboardImage() (string, error) {
|
|
// Wayland (wl-paste) then X11 (xclip); both write image bytes to stdout.
|
|
for _, c := range [][]string{
|
|
{"wl-paste", "--type", "image/png", "--no-newline"},
|
|
{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
|
|
} {
|
|
cmd := exec.Command(c[0], c[1:]...)
|
|
cmd.Env = secrets.ProcessEnv()
|
|
if out, err := cmd.Output(); err == nil && len(out) > 0 {
|
|
return SaveImageBytes("", out)
|
|
}
|
|
}
|
|
return "", fmt.Errorf("clipboard image paste needs wl-paste (Wayland) or xclip (X11)")
|
|
}
|
|
|
|
func ImageDataURL(path string) (string, error) {
|
|
raw, mime, err := readAttachmentImage(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
|
|
}
|
|
|
|
// visionImageDataURL reads an attachment and, unlike ImageDataURL (which feeds
|
|
// the desktop preview at full resolution), downscales/recompresses it before
|
|
// base64 so an oversized photo doesn't balloon the request bytes and image
|
|
// tokens. Best-effort: an undecodable format passes through at original size.
|
|
func visionImageDataURL(path string) (string, error) {
|
|
raw, mime, err := readAttachmentImage(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
raw, mime = compressForVision(raw, mime)
|
|
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
|
|
}
|
|
|
|
func readAttachmentImage(path string) (raw []byte, mime string, err error) {
|
|
clean, err := cleanAttachmentPath(path)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
info, err := os.Lstat(clean)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return nil, "", fmt.Errorf("attachment path must not be a symlink")
|
|
}
|
|
if info.IsDir() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
|
|
return nil, "", fmt.Errorf("attachment image must be between 1 byte and 10 MB")
|
|
}
|
|
f, err := os.Open(clean)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer f.Close()
|
|
opened, err := f.Stat()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if !os.SameFile(info, opened) {
|
|
return nil, "", fmt.Errorf("attachment changed while opening")
|
|
}
|
|
raw, err = io.ReadAll(io.LimitReader(f, maxImageAttachmentBytes+1))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
|
|
return nil, "", fmt.Errorf("attachment image must be between 1 byte and 10 MB")
|
|
}
|
|
if after, err := f.Stat(); err != nil {
|
|
return nil, "", err
|
|
} else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
|
|
return nil, "", fmt.Errorf("attachment changed while reading")
|
|
}
|
|
mime = detectedImageMime(raw)
|
|
if mime == "" {
|
|
return nil, "", fmt.Errorf("attachment is not an image")
|
|
}
|
|
return raw, mime, nil
|
|
}
|
|
|
|
func cleanAttachmentPath(path string) (string, error) {
|
|
if filepath.IsAbs(path) {
|
|
return "", fmt.Errorf("attachment path must be relative")
|
|
}
|
|
clean := filepath.Clean(filepath.FromSlash(path))
|
|
root := filepath.Join(".reasonix", "attachments")
|
|
if clean == "." || clean == root || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || !strings.HasPrefix(clean, root+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("attachment path is outside .reasonix/attachments")
|
|
}
|
|
if err := ensureAttachmentRoot(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := rejectSymlinkComponents(clean, root); err != nil {
|
|
return "", err
|
|
}
|
|
return clean, nil
|
|
}
|
|
|
|
func rejectSymlinkComponents(path, root string) error {
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
|
return fmt.Errorf("attachment path is outside .reasonix/attachments")
|
|
}
|
|
cur := root
|
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
|
if part == "" || part == "." {
|
|
continue
|
|
}
|
|
cur = filepath.Join(cur, part)
|
|
info, err := os.Lstat(cur)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("attachment path must not contain symlinks")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureAttachmentRoot() error {
|
|
return ensureAttachmentRootIn(".")
|
|
}
|
|
|
|
func ensureAttachmentRootIn(base string) error {
|
|
root := filepath.Join(base, ".reasonix", "attachments")
|
|
if info, err := os.Lstat(root); err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("attachment directory must not be a symlink")
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("attachment path exists but is not a directory")
|
|
}
|
|
return nil
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return fmt.Errorf("attachment directory is invalid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func saveDarwinClipboardImage() (string, error) {
|
|
for _, class := range []string{"PNGf", "JPEG"} {
|
|
if rel, err := saveDarwinClipboardClass(class); err == nil {
|
|
return rel, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("clipboard does not contain a supported image")
|
|
}
|
|
|
|
func saveDarwinClipboardClass(class string) (string, error) {
|
|
if err := ensureAttachmentRoot(); err != nil {
|
|
return "", err
|
|
}
|
|
rel, f, err := createAttachmentFile(".bin")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
_ = os.Remove(rel)
|
|
return "", err
|
|
}
|
|
abs, err := filepath.Abs(rel)
|
|
if err != nil {
|
|
_ = os.Remove(rel)
|
|
return "", err
|
|
}
|
|
script := fmt.Sprintf(`
|
|
set outPath to POSIX file %q
|
|
try
|
|
set img to the clipboard as «class %s»
|
|
on error
|
|
error "clipboard does not contain this image type"
|
|
end try
|
|
set f to open for access outPath with write permission
|
|
try
|
|
set eof f to 0
|
|
write img to f
|
|
close access f
|
|
on error errMsg
|
|
try
|
|
close access f
|
|
end try
|
|
error errMsg
|
|
end try
|
|
`, abs, class)
|
|
clip := exec.Command("osascript", "-e", script)
|
|
clip.Env = secrets.ProcessEnv()
|
|
if out, err := clip.CombinedOutput(); err != nil {
|
|
_ = os.Remove(rel)
|
|
return "", fmt.Errorf("read clipboard image: %s", strings.TrimSpace(string(out)))
|
|
}
|
|
raw, err := os.ReadFile(rel)
|
|
_ = os.Remove(rel)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return SaveImageBytes("", raw)
|
|
}
|
|
|
|
func createAttachmentFile(ext string) (string, *os.File, error) {
|
|
return createAttachmentFileIn(".", ext)
|
|
}
|
|
|
|
func createAttachmentFileIn(base, ext string) (string, *os.File, error) {
|
|
for range maxAttachmentCreateAttempts {
|
|
rel := attachmentPath(ext)
|
|
f, err := os.OpenFile(filepath.Join(base, rel), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
|
if os.IsExist(err) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return rel, f, nil
|
|
}
|
|
return "", nil, fmt.Errorf("create unique attachment path")
|
|
}
|
|
|
|
func attachmentPath(ext string) string {
|
|
seq := attachmentPathSeq.Add(1)
|
|
name := fmt.Sprintf("clipboard-%s-%06d%s", attachmentNow().Format("20060102-150405.000000"), seq, ext)
|
|
return filepath.Join(".reasonix", "attachments", name)
|
|
}
|
|
|
|
func detectedImageMime(raw []byte) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
mime := http.DetectContentType(raw[:min(len(raw), 512)])
|
|
if imageExt(mime) == "" {
|
|
return ""
|
|
}
|
|
return mime
|
|
}
|
|
|
|
func imageExt(mime string) string {
|
|
switch strings.ToLower(strings.TrimSpace(mime)) {
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "image/webp":
|
|
return ".webp"
|
|
}
|
|
return ""
|
|
}
|