Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This application provides a bucket synchronisation service that provides a way t
* **Pull synchronisation**: Can process S3 upload event notifications from a message queue, downloading new or updated files to the local machine.
* **Multiple Storage Backends**: Supports both S3-compatible storage (MinIO, AWS S3) and WebDAV servers.
* **Secure Protocols**: Supports both HTTP (`webdav://`) and HTTPS (`webdavs://`) WebDAV connections.
* **Reliability**: Automatic retry logic with exponential backoff for failed operations, and timeouts to prevent hanging.
* **Desktop Notifications**: Optional desktop notifications when files are successfully uploaded or downloaded (Linux/macOS/Windows).
* **Custom Filtering**: (TODO) Ability to process the file with a script before upload/download, which useful for removing or obfuscating sensitive data.

## Usage
Expand Down Expand Up @@ -49,6 +49,31 @@ WebDAV credentials are embedded directly in the URL. No separate remote configur
- Username/password authentication
- Compatible with popular WebDAV servers (Apache, nginx, Nextcloud, etc.)

## Desktop Notifications

bucketsyncd can optionally send desktop notifications when files are successfully uploaded or downloaded. This provides immediate visual feedback for sync operations.

### Configuration

Add to your `config.yaml`:

```yaml
enable_notifications: true
```

### Platform Support

- **Linux**: Uses `notify-send` (requires `libnotify-bin` package)
- **macOS**: Uses system notifications via `osascript`
- **Windows**: Uses PowerShell MessageBox

### Examples

- "Uploaded statement.pdf to S3"
- "Downloaded scan001.jpg"

Notifications are sent asynchronously and won't block sync operations.

## Configuration example

Copy the [`example/bucketsyncd.service`] systemd unit file to your home directory as `~/.config/systemd/user/bucketsyncd.service`. Update it to reflect the locations of where your binary and configuration files are.
Expand Down
11 changes: 6 additions & 5 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ type Outbound struct {
}

type Config struct {
LogLevel string `yaml:"log_level"`
LogJSON bool `yaml:"log_json"`
Outbound []Outbound `yaml:"outbound"`
Inbound []Inbound `yaml:"inbound"`
Remotes []Remote `yaml:"remotes"`
LogLevel string `yaml:"log_level"`
LogJSON bool `yaml:"log_json"`
EnableNotifications bool `yaml:"enable_notifications"`
Outbound []Outbound `yaml:"outbound"`
Inbound []Inbound `yaml:"inbound"`
Remotes []Remote `yaml:"remotes"`
}

func readConfig(filename string) error {
Expand Down
3 changes: 3 additions & 0 deletions example/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
log_level: debug
#log_level: info

# Enable desktop notifications for uploads/downloads
enable_notifications: true

# Remote buckets to sync to/from
remotes:
- name: minio1
Expand Down
10 changes: 6 additions & 4 deletions inbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,12 @@ func inboundWithContext(ctx context.Context, in Inbound) {
continue
}

log.WithFields(lf).WithFields(log.Fields{
"filename": localFilename,
"size": size,
}).Info("retrieved remote object to local file")
log.WithFields(lf).WithFields(log.Fields{
"filename": localFilename,
"size": size,
}).Info("retrieved remote object to local file")

SendNotification("bucketsyncd", fmt.Sprintf("Downloaded %s", filepath.Base(key)))

// Acknowledge queued message after successful processing
err = d.Ack(false)
Expand Down
43 changes: 43 additions & 0 deletions notifications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import (
"fmt"
"os/exec"
"runtime"

log "github.com/sirupsen/logrus"
)

// SendNotification sends a desktop notification
func SendNotification(title, message string) {
if !config.EnableNotifications {
return
}

var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("notify-send", title, message) // #nosec G204 - This is intentional use of exec.Command for notifications
case "darwin":
// Use osascript for macOS notifications
script := fmt.Sprintf(`display notification "%s" with title "%s"`, message, title)
cmd = exec.Command("osascript", "-e", script) // #nosec G204 - This is intentional use of exec.Command for notifications
case "windows":
// Use PowerShell for Windows notifications
script := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('%s', '%s')`, message, title)
cmd = exec.Command("powershell", "-Command", script) // #nosec G204 - This is intentional use of exec.Command for notifications
default:
log.Warn("Notifications not supported on this platform")
return
}

// Run the command in background, don't wait
go func() {
if err := cmd.Run(); err != nil {
log.WithFields(log.Fields{
"os": runtime.GOOS,
"error": err,
}).Debug("Failed to send notification")
}
}()
}
56 changes: 56 additions & 0 deletions notifications_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"os/exec"
"runtime"
"testing"
)

// TestSendNotification tests the notification functionality
func TestSendNotification(t *testing.T) {
// Save original config
originalConfig := config
defer func() { config = originalConfig }()

// Test with notifications disabled
config = Config{EnableNotifications: false}
SendNotification("Test", "Message")
// Should not attempt to send notification

// Test with notifications enabled
config = Config{EnableNotifications: true}

// This will attempt to send a notification, but since it's async and may fail,
// we mainly test that it doesn't panic and calls the right command
SendNotification("bucketsyncd", "Test notification")

// Test different platforms (we can't easily test the actual commands without mocking)
switch runtime.GOOS {
case "linux":
// Should use notify-send
_ = exec.Command("notify-send", "--version")
case "darwin":
// Should use osascript
_ = exec.Command("osascript", "--version")
case "windows":
// Should use powershell
_ = exec.Command("powershell", "-Command", "Get-Host")
}
}

// TestNotificationConfig tests that the config option works
func TestNotificationConfig(t *testing.T) {
// Test parsing config with notifications
config = Config{}
// The config is parsed in readConfig, but for this test we can just set it
config.EnableNotifications = true

if !config.EnableNotifications {
t.Error("EnableNotifications should be true")
}

config.EnableNotifications = false
if config.EnableNotifications {
t.Error("EnableNotifications should be false")
}
}
4 changes: 4 additions & 0 deletions outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ func outbound(o Outbound) {
"remote_path": remotePath,
}).Info("successfully uploaded file to WebDAV")

SendNotification("bucketsyncd", fmt.Sprintf("Uploaded %s to WebDAV", filename))

} else {
// Handle S3 upload (existing logic)
endpoint := u.Hostname()
Expand Down Expand Up @@ -239,6 +241,8 @@ func outbound(o Outbound) {
"awsFileKey": awsFileKey,
"size": fs.Size(),
}).Info("uploaded to S3")

SendNotification("bucketsyncd", fmt.Sprintf("Uploaded %s to S3", filename))
}

case err, ok := <-watcher.Errors:
Expand Down
Loading