Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

352 lines
13 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/base64"
"fmt"
"os/exec"
"regexp"
"runtime"
"strings"
"github.com/larksuite/cli/errs"
)
// readClipboardImageBytes reads the current clipboard image and returns the
// raw PNG bytes in memory. No temporary files are created on any platform;
// all platform tools emit image bytes (or an encoded form) on stdout.
//
// Platform support:
//
// macOS — osascript (built-in, no extra deps)
// Windows — powershell + System.Windows.Forms (built-in), output as base64
// Linux — xclip (X11), wl-paste (Wayland), or xsel (X11 fallback),
// tried in that order; returns a clear error if none is found.
func readClipboardImageBytes() ([]byte, error) {
var data []byte
var err error
switch runtime.GOOS {
case "darwin":
data, err = readClipboardDarwin()
case "windows":
data, err = readClipboardWindows()
case "linux":
data, err = readClipboardLinux()
default:
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image upload is not supported on %s", runtime.GOOS)
}
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
}
return data, nil
}
// reBase64DataURI matches a data URI image embedded in clipboard text content,
// e.g. data:image/jpeg;base64,/9j/4AAQ...
// The character class covers both standard (+/) and URL-safe (-_) base64
// alphabets, plus ASCII whitespace: HTML and RTF clipboard payloads commonly
// fold long base64 at 76 chars (standard MIME folding), so whitespace must be
// captured as part of the payload for the downstream strings.Fields strip to
// actually have something to normalise. Terminators like ", <, ), ; remain
// outside the class so the match still ends at the URI boundary.
var reBase64DataURI = regexp.MustCompile(`data:(image/[^;]+);base64,([A-Za-z0-9+/\-_\s]+=*)`)
// readClipboardDarwin reads the clipboard image on macOS and returns image bytes.
//
// Strategy:
// 1. Ask osascript for the clipboard as PNG (hex literal on stdout) → decode.
// Native macOS screenshots and most image-producing apps place PNG on the
// pasteboard directly.
// 2. Scan all text-based clipboard formats (HTML, RTF, plain text) for an
// embedded base64 data URI image (e.g. images copied from Feishu / browsers).
// Decoded payload is validated against known image magic bytes so text
// clipboards that happen to mention a data URI literally are not treated
// as image data.
//
// No external dependencies required — osascript ships with macOS.
func readClipboardDarwin() ([]byte, error) {
// Attempt 1: PNG via osascript hex literal on stdout.
// Use Output() + separate stderr capture so osascript diagnostics
// (locale warnings, AppleEvent permission prompts, etc.) do not
// contaminate the decoded payload or mask real failures.
out, stderrText, runErr := runOsascript("get the clipboard as «class PNGf»")
if runErr == nil && len(out) > 0 {
if data, decErr := decodeOsascriptData(strings.TrimSpace(string(out))); decErr == nil && len(data) > 0 {
return data, nil
}
}
// First-attempt failure is expected for non-image clipboards — fall through
// to the base64 scan. Keep the stderr text for the final error message in
// case every attempt ends up empty-handed.
// Attempt 2: scan text-based clipboard formats for an embedded base64 data URI.
// Covers HTML (Feishu, Chrome, Safari), RTF, and plain text — tried in order.
if imgData := extractBase64ImageFromClipboard(); imgData != nil {
return imgData, nil
}
if stderrText != "" {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (osascript: %s)", stderrText)
}
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
}
// runOsascript invokes osascript with a single AppleScript expression and
// returns stdout, a trimmed stderr string, and the exec error separately.
// Using Output() (rather than CombinedOutput) keeps stderr out of the decoded
// payload, while the captured stderr is still available for error messages.
func runOsascript(expr string) (stdout []byte, stderrText string, err error) {
cmd := exec.Command("osascript", "-e", expr)
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, err = cmd.Output()
stderrText = strings.TrimSpace(stderr.String())
return stdout, stderrText, err
}
// clipboardTextFormats lists the osascript type coercions to try when looking
// for an embedded base64 data-URI image in text-based clipboard formats.
// Ordered by likelihood of containing an embedded image.
var clipboardTextFormats = []struct {
classCode string // 4-char OSType used in «class XXXX»
asExpr string // AppleScript coercion expression
}{
{"HTML", "get the clipboard as «class HTML»"},
{"RTF ", "get the clipboard as «class RTF »"},
{"utf8", "get the clipboard as «class utf8»"},
{"TEXT", "get the clipboard as string"},
}
// extractBase64ImageFromClipboard iterates text clipboard formats and returns
// the first decoded image payload found, or nil if none contains image data.
// Decoded bytes are validated against known image magic headers so that
// text clipboards containing a literal `data:image/...;base64,...` fragment
// (e.g. a tutorial, a code sample, pasted HTML source) are not silently
// uploaded as an image.
func extractBase64ImageFromClipboard() []byte {
for _, f := range clipboardTextFormats {
out, _, err := runOsascript(f.asExpr)
if err != nil || len(out) == 0 {
continue
}
raw := strings.TrimSpace(string(out))
decoded, err := decodeOsascriptData(raw)
if err != nil || len(decoded) == 0 {
continue
}
m := reBase64DataURI.FindSubmatch(decoded)
if m == nil {
continue
}
// HTML/RTF clipboard content often line-wraps base64 at 76 chars; strip
// all ASCII whitespace before decoding so wrapped payloads are not missed.
// Accept both standard and URL-safe base64 (some apps emit URL-safe).
b64 := strings.Join(strings.Fields(string(m[2])), "")
imgData, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
imgData, err = base64.URLEncoding.DecodeString(b64)
}
if err != nil || len(imgData) == 0 {
continue
}
if !hasKnownImageMagic(imgData) {
// Decoded payload does not look like a real image — e.g. the
// clipboard is a documentation sample that mentions data URIs.
// Keep looking in the next format rather than upload garbage.
continue
}
return imgData
}
return nil
}
// decodeOsascriptData converts the «data XXXX<hex>» literal that osascript
// emits for binary clipboard classes into raw bytes.
// If the input does not match the literal format, the raw bytes are returned as-is.
func decodeOsascriptData(s string) ([]byte, error) {
// Format: «data HTML3C6D657461...»
const prefix = "\xc2\xab" + "data " // « in UTF-8 followed by "data "
if !strings.HasPrefix(s, prefix) {
// plain string — return as-is
return []byte(s), nil
}
// strip «data XXXX (4-char class code follows immediately, no space) and trailing »
s = s[len(prefix):]
if len(s) >= 4 {
s = s[4:] // skip class code, e.g. "HTML", "TIFF", "PNGf"
}
s = strings.TrimSuffix(s, "\xc2\xbb") // »
s = strings.TrimSpace(s)
return decodeHex(s)
}
// decodeHex decodes an uppercase hex string (as produced by osascript) to bytes.
func decodeHex(h string) ([]byte, error) {
if len(h)%2 != 0 {
return nil, fmt.Errorf("odd hex length") //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
}
b := make([]byte, len(h)/2)
for i := 0; i < len(h); i += 2 {
hi := hexVal(h[i])
lo := hexVal(h[i+1])
if hi < 0 || lo < 0 {
return nil, fmt.Errorf("invalid hex char at %d", i) //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
}
b[i/2] = byte(hi<<4 | lo)
}
return b, nil
}
func hexVal(c byte) int {
switch {
case c >= '0' && c <= '9':
return int(c - '0')
case c >= 'a' && c <= 'f':
return int(c-'a') + 10
case c >= 'A' && c <= 'F':
return int(c-'A') + 10
}
return -1
}
// readClipboardWindows uses PowerShell to export the clipboard image as PNG,
// writing it as base64 to stdout and decoding in Go (no temp files).
func readClipboardWindows() ([]byte, error) {
script := `
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$img = [System.Windows.Forms.Clipboard]::GetImage()
if ($img -eq $null) { Write-Error 'clipboard contains no image data'; exit 1 }
$ms = New-Object System.IO.MemoryStream
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
[Convert]::ToBase64String($ms.ToArray())
`
// Use Output() + captured stderr so PowerShell diagnostics surface in the
// error message but never corrupt the base64 stdout we need to decode.
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg).WithCause(err)
}
b64 := strings.TrimSpace(string(out))
data, decErr := base64.StdEncoding.DecodeString(b64)
if decErr != nil {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image decode failed: %s", decErr).WithCause(decErr)
}
return data, nil
}
// pngMagic is the 8-byte PNG signature used to validate clipboard output from
// tools that cannot negotiate MIME types (e.g. xsel).
var pngMagic = []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
func hasPNGMagic(b []byte) bool {
return len(b) >= len(pngMagic) && string(b[:len(pngMagic)]) == string(pngMagic)
}
// imageMagics enumerates the leading-byte signatures we accept as "this is a
// real image payload" when a text clipboard supplies a base64 data URI. The
// set mirrors the formats the Lark upload endpoints already accept; other
// rare formats fall through so the caller skips to the next clipboard format.
var imageMagics = [][]byte{
// PNG
{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
// JPEG (SOI)
{0xff, 0xd8, 0xff},
// GIF87a / GIF89a
[]byte("GIF87a"),
[]byte("GIF89a"),
// WebP: "RIFF????WEBP" — check the RIFF marker only; the WEBP marker
// lives at offset 8, validated separately below.
[]byte("RIFF"),
// BMP
[]byte("BM"),
}
// hasKnownImageMagic reports whether the first bytes of b match any of the
// image signatures we trust. RIFF is further constrained to actual WebP
// streams to avoid false positives on other RIFF-based formats (WAV, AVI).
func hasKnownImageMagic(b []byte) bool {
for _, magic := range imageMagics {
if len(b) < len(magic) {
continue
}
if string(b[:len(magic)]) != string(magic) {
continue
}
// RIFF header must be followed at offset 8 by "WEBP" to count as an image.
if string(magic) == "RIFF" {
if len(b) >= 12 && string(b[8:12]) == "WEBP" {
return true
}
continue
}
return true
}
return false
}
// readClipboardLinux tries xclip (X11), wl-paste (Wayland), and xsel (X11)
// in order, returning the PNG bytes from the first available tool.
//
// xclip and wl-paste request the image/png MIME type directly; xsel cannot
// negotiate MIME types so its output is validated against the PNG magic header.
// If a tool is present but fails or returns non-PNG data, the error is
// preserved so users see a meaningful message instead of "no tool found".
func readClipboardLinux() ([]byte, error) {
type tool struct {
name string
args []string
validatePNG bool // true when the tool cannot request image/png by MIME
}
tools := []tool{
{"xclip", []string{"-selection", "clipboard", "-t", "image/png", "-o"}, false},
{"wl-paste", []string{"--type", "image/png"}, false},
{"xsel", []string{"--clipboard", "--output"}, true},
}
var lastErr error
foundTool := false
for _, t := range tools {
if _, lookErr := exec.LookPath(t.name); lookErr != nil {
continue
}
foundTool = true
out, err := exec.Command(t.name, t.args...).Output()
if err != nil {
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image read failed via %s: %s", t.name, err).WithCause(err)
continue
}
if len(out) == 0 {
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (%s returned empty output)", t.name)
continue
}
if t.validatePNG && !hasPNGMagic(out) {
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no PNG image data (%s output is not a PNG)", t.name)
continue
}
return out, nil
}
if foundTool && lastErr != nil {
return nil, lastErr
}
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"clipboard image read failed: no supported tool found. "+
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager "+
"(apt, dnf, pacman, apk, brew, etc.).")
}