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
+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
}