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 }