feat: implement security, discovery, history, file transfer, web UI (P0-P3)

This commit is contained in:
2026-08-14 09:45:54 -04:00
parent 8281478f0c
commit 884415c1c1
32 changed files with 1923 additions and 200 deletions
+14 -5
View File
@@ -24,6 +24,7 @@ var (
procGlobalLock = kernel32.NewProc("GlobalLock")
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
procGlobalFree = kernel32.NewProc("GlobalFree")
procGlobalSize = kernel32.NewProc("GlobalSize")
)
type windowsClipboard struct{}
@@ -52,9 +53,16 @@ func (c *windowsClipboard) Read() (string, error) {
defer func() { _, _, _ = procGlobalUnlock.Call(h) }()
// Query the actual size of the global memory block so we never read past it.
size, _, _ := procGlobalSize.Call(h)
if size == 0 {
return "", nil
}
units := size / 2 // bytes → UTF-16 code units
// Lock and read the global memory block.
// Use a helper to keep the uintptr→unsafe.Pointer conversion close to the syscall.
mem := lockAndRead(h)
mem := lockAndRead(h, units)
if mem == nil {
return "", fmt.Errorf("clipboard read: GlobalLock failed")
}
@@ -103,9 +111,10 @@ func (c *windowsClipboard) Write(text string) error {
return nil
}
// lockAndRead locks a global memory handle and returns its UTF-16 data.
// Returns nil on failure. Caller must call GlobalUnlock after use.
func lockAndRead(h uintptr) []uint16 {
// lockAndRead locks a global memory handle and returns its UTF-16 data of the
// given length (in code units). Returns nil on failure. Caller must call
// GlobalUnlock after use.
func lockAndRead(h uintptr, units uintptr) []uint16 {
p, _, _ := procGlobalLock.Call(h)
if p == 0 {
return nil
@@ -113,7 +122,7 @@ func lockAndRead(h uintptr) []uint16 {
// Use unsafe.Add to derive a pointer from nil; the uintptr came from
// GlobalLock (GMEM_FIXED) and is a valid virtual address, not a Go pointer.
ptr := unsafe.Add(unsafe.Pointer(nil), p)
return unsafe.Slice((*uint16)(ptr), 1<<20)
return unsafe.Slice((*uint16)(ptr), units)
}
// lockAndWrite locks a global memory handle and copies UTF-16 data into it.
+124 -19
View File
@@ -1,10 +1,13 @@
// Package config parses the clip-sync TOML configuration file and applies
// environment variable overrides.
//
// Config file location: ~/.config/clip-sync/peers.toml
// Environment overrides:
// CLIP_SYNC_PORT — override daemon.port
// CLIP_SYNC_POLL_MS — override daemon.poll_interval_ms
// Config file location: ~/.config/clip-sync/peers.toml (overridable with
// the --config flag). Environment overrides:
//
// CLIP_SYNC_PORT — override daemon.port
// CLIP_SYNC_POLL_MS — override daemon.poll_interval_ms
// CLIP_SYNC_KEY — override security.shared_key
// CLIP_SYNC_MAX_BODY_BYTES — override daemon.max_body_bytes
package config
import (
@@ -19,59 +22,118 @@ import (
// Config represents the full clip-sync configuration.
type Config struct {
Daemon DaemonConfig `toml:"daemon"`
Peers []PeerConfig `toml:"peers"`
Daemon DaemonConfig `toml:"daemon"`
Security SecurityConfig `toml:"security"`
Peers []PeerConfig `toml:"peers"`
}
// DaemonConfig holds daemon-level settings.
type DaemonConfig struct {
Port int `toml:"port"`
PollIntervalMs int `toml:"poll_interval_ms"`
Port int `toml:"port"`
PollIntervalMs int `toml:"poll_interval_ms"`
MaxBodyBytes int64 `toml:"max_body_bytes"`
HistorySize int `toml:"history_size"`
Discovery bool `toml:"discovery"`
Notify bool `toml:"notify"`
ReceiveFiles bool `toml:"receive_files"`
ReceiveDir string `toml:"receive_dir"`
}
// SecurityConfig holds peer authentication and transport security settings.
type SecurityConfig struct {
SharedKey string `toml:"shared_key"`
AllowedOrigins []string `toml:"allowed_origins"`
TLS bool `toml:"tls"`
CertFile string `toml:"cert_file"`
KeyFile string `toml:"key_file"`
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
}
// PeerConfig represents one remote clip-sync peer.
type PeerConfig struct {
Name string `toml:"name"`
Addr string `toml:"addr"`
TLS bool `toml:"tls"` // per-peer override of security.tls
}
// Default values.
const (
DefaultPort = 9137
DefaultPollIntervalMs = 500
DefaultMaxBodyBytes = 10 << 20 // 10 MiB
DefaultHistorySize = 50
ConfigDir = ".config/clip-sync"
ConfigFile = "peers.toml"
)
// Load reads the config file from ~/.config/clip-sync/peers.toml and applies
// environment variable overrides.
func Load() (*Config, error) {
// DefaultConfigPath returns the default config file path (~/.config/clip-sync/peers.toml).
func DefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("config: cannot find home directory: %w", err)
return "", fmt.Errorf("config: cannot find home directory: %w", err)
}
return filepath.Join(home, ConfigDir, ConfigFile), nil
}
cfgPath := filepath.Join(home, ConfigDir, ConfigFile)
// DefaultCertFile returns the default TLS certificate path.
func DefaultCertFile() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("config: cannot find home directory: %w", err)
}
return filepath.Join(home, ConfigDir, "cert.pem"), nil
}
// DefaultKeyFile returns the default TLS private key path.
func DefaultKeyFile() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("config: cannot find home directory: %w", err)
}
return filepath.Join(home, ConfigDir, "key.pem"), nil
}
// Load reads the config file from the default location and applies
// environment variable overrides.
func Load() (*Config, error) {
path, err := DefaultConfigPath()
if err != nil {
return nil, err
}
return LoadFrom(path)
}
// LoadFrom reads the config file at the given path and applies environment
// variable overrides. If the file does not exist, defaults are used.
func LoadFrom(path string) (*Config, error) {
cfg := &Config{
Daemon: DaemonConfig{
Port: DefaultPort,
PollIntervalMs: DefaultPollIntervalMs,
MaxBodyBytes: DefaultMaxBodyBytes,
HistorySize: DefaultHistorySize,
},
}
if _, err := toml.DecodeFile(cfgPath, cfg); err != nil {
if _, err := toml.DecodeFile(path, cfg); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("config: cannot parse %s: %w", cfgPath, err)
return nil, fmt.Errorf("config: cannot parse %s: %w", path, err)
}
// File doesn't exist — use defaults (no peers configured).
}
// Environment variable overrides.
if err := applyEnvOverrides(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func applyEnvOverrides(cfg *Config) error {
if v := os.Getenv("CLIP_SYNC_PORT"); v != "" {
port, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("config: invalid CLIP_SYNC_PORT: %w", err)
return fmt.Errorf("config: invalid CLIP_SYNC_PORT: %w", err)
}
cfg.Daemon.Port = port
}
@@ -79,12 +141,24 @@ func Load() (*Config, error) {
if v := os.Getenv("CLIP_SYNC_POLL_MS"); v != "" {
ms, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("config: invalid CLIP_SYNC_POLL_MS: %w", err)
return fmt.Errorf("config: invalid CLIP_SYNC_POLL_MS: %w", err)
}
cfg.Daemon.PollIntervalMs = ms
}
return cfg, nil
if v := os.Getenv("CLIP_SYNC_KEY"); v != "" {
cfg.Security.SharedKey = v
}
if v := os.Getenv("CLIP_SYNC_MAX_BODY_BYTES"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("config: invalid CLIP_SYNC_MAX_BODY_BYTES: %w", err)
}
cfg.Daemon.MaxBodyBytes = n
}
return nil
}
// PollInterval returns the poll interval as a time.Duration.
@@ -96,3 +170,34 @@ func (c *Config) PollInterval() time.Duration {
func (c *Config) ListenAddr() string {
return fmt.Sprintf(":%d", c.Daemon.Port)
}
// PeerUsesTLS reports whether the given peer should be contacted over TLS.
// A per-peer override wins over the global security.tls setting.
func (c *Config) PeerUsesTLS(p PeerConfig) bool {
if p.TLS {
return true
}
return c.Security.TLS
}
// MaxBodyBytes returns the effective request body size limit, clamped to a
// sane minimum so a zero/negative config value never disables the limit.
func (c *Config) MaxBodyBytes() int64 {
if c.Daemon.MaxBodyBytes > 0 {
return c.Daemon.MaxBodyBytes
}
return DefaultMaxBodyBytes
}
// ReceiveDir returns the directory where received files are written, resolving
// the default (~/Downloads/clip-sync) when not configured.
func (c *Config) ReceiveDir() string {
if c.Daemon.ReceiveDir != "" {
return c.Daemon.ReceiveDir
}
home, err := os.UserHomeDir()
if err != nil {
return "clip-sync-received"
}
return filepath.Join(home, "Downloads", "clip-sync")
}
+90 -17
View File
@@ -1,5 +1,5 @@
// Package daemon orchestrates the main clip-sync loop: poll the local clipboard,
// broadcast changes to peers, and receive incoming clips via the HTTP server.
// broadcast changes to peers, and receive incoming clips via the HTTP(S) server.
package daemon
import (
@@ -12,17 +12,23 @@ import (
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
"git.dracodev.net/Projets/clip-sync/internal/discovery"
"git.dracodev.net/Projets/clip-sync/internal/history"
"git.dracodev.net/Projets/clip-sync/internal/notify"
"git.dracodev.net/Projets/clip-sync/internal/peer"
"git.dracodev.net/Projets/clip-sync/internal/server"
)
// Daemon is the main clip-sync process.
type Daemon struct {
cfg *config.Config
clipboard clipboard.Clipboard
filter *dedup.Filter
server *server.Server
cfg *config.Config
clipboard clipboard.Clipboard
filter *dedup.Filter
server *server.Server
broadcaster *peer.Broadcaster
history *history.Store
origin string
discovery discovery.Discovery
}
// New creates a Daemon from the given configuration.
@@ -32,7 +38,18 @@ func New(cfg *config.Config) (*Daemon, error) {
return nil, err
}
return newWithClipboard(cfg, cb)
origin, err := os.Hostname()
if err != nil {
return nil, err
}
// History is best-effort: if we cannot resolve a path, run without it.
hist := (*history.Store)(nil)
if p, err := history.DefaultPath(); err == nil {
hist = history.New(p, cfg.Daemon.HistorySize)
}
return newDaemon(cfg, cb, origin, hist)
}
// newWithClipboard creates a Daemon with a pre-existing clipboard (used by tests).
@@ -42,6 +59,10 @@ func newWithClipboard(cfg *config.Config, cb clipboard.Clipboard) (*Daemon, erro
// newWithClipboardOrigin creates a Daemon with a pre-existing clipboard and explicit origin.
func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin string) (*Daemon, error) {
return newDaemon(cfg, cb, origin, nil)
}
func newDaemon(cfg *config.Config, cb clipboard.Clipboard, origin string, hist *history.Store) (*Daemon, error) {
if origin == "" {
var err error
origin, err = os.Hostname()
@@ -51,34 +72,82 @@ func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin s
}
filter := dedup.NewFilter(origin)
broadcaster := peer.NewBroadcaster(cfg.Peers, origin)
srv := server.New(cfg.ListenAddr(), cb, filter)
broadcaster := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
SharedKey: cfg.Security.SharedKey,
TLS: cfg.Security.TLS,
CertFile: cfg.Security.CertFile,
InsecureSkipVerify: cfg.Security.InsecureSkipVerify,
})
return &Daemon{
var notifier notify.Notifier
if cfg.Daemon.Notify {
notifier = notify.New()
}
srv := server.New(server.Options{
Addr: cfg.ListenAddr(),
SharedKey: cfg.Security.SharedKey,
AllowedOrigins: cfg.Security.AllowedOrigins,
MaxBodyBytes: cfg.MaxBodyBytes(),
History: hist,
Notifier: notifier,
ReceiveFiles: cfg.Daemon.ReceiveFiles,
ReceiveDir: cfg.ReceiveDir(),
}, cb, filter)
d := &Daemon{
cfg: cfg,
clipboard: cb,
filter: filter,
server: srv,
broadcaster: broadcaster,
}, nil
history: hist,
origin: origin,
}
if cfg.Daemon.Discovery {
d.discovery = discovery.New(origin, cfg.Daemon.Port, broadcaster)
}
return d, nil
}
// Run starts the daemon loop. It blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error {
// Start the HTTP server in a background goroutine.
// Start the HTTP(S) server in a background goroutine.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr())
if err := d.server.ListenAndServe(); err != nil {
// ListenAndServe returns http.ErrServerClosed on graceful shutdown.
if ctx.Err() == nil {
log.Printf("clip-sync: server error: %v", err)
var err error
if d.cfg.Security.TLS {
certFile, keyFile := d.cfg.Security.CertFile, d.cfg.Security.KeyFile
if certFile == "" {
certFile, _ = config.DefaultCertFile()
}
if keyFile == "" {
keyFile, _ = config.DefaultKeyFile()
}
log.Printf("clip-sync: listening on %s (TLS)", d.cfg.ListenAddr())
err = d.server.ListenAndServeTLS(certFile, keyFile)
} else {
log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr())
err = d.server.ListenAndServe()
}
if err != nil && ctx.Err() == nil {
log.Printf("clip-sync: server error: %v", err)
}
}()
// Start mDNS discovery if enabled.
if d.discovery != nil {
if err := d.discovery.Start(); err != nil {
log.Printf("clip-sync: discovery: %v", err)
} else {
defer d.discovery.Stop()
}
}
// Main clipboard polling loop.
ticker := time.NewTicker(d.cfg.PollInterval())
defer ticker.Stop()
@@ -111,8 +180,12 @@ func (d *Daemon) Run(ctx context.Context) error {
// Record our write so the filter can block echoes.
d.filter.MarkWritten(text)
if d.history != nil {
d.history.Append(history.Entry{Text: text, Origin: d.origin, Ts: time.Now().UnixNano()})
}
// Broadcast to all peers.
if len(d.cfg.Peers) > 0 {
if len(d.cfg.Peers) > 0 || d.discovery != nil {
d.broadcaster.Broadcast(text)
}
}
+4 -4
View File
@@ -21,10 +21,10 @@ const defaultCacheSize = 20
// Filter prevents clipboard sync loops.
type Filter struct {
mu sync.Mutex
origin string // our hostname
cache []string // ring buffer of content hashes
cacheIdx int // write position in ring buffer
lastWriteTs int64 // nanosecond timestamp of our last local write
origin string // our hostname
cache []string // ring buffer of content hashes
cacheIdx int // write position in ring buffer
lastWriteTs int64 // nanosecond timestamp of our last local write
}
// NewFilter creates a new deduplication filter for the given origin hostname.
+185
View File
@@ -0,0 +1,185 @@
// Package discovery provides optional mDNS-based peer auto-discovery.
//
// Each instance advertises a `_clip-sync._tcp` service and periodically browses
// for other instances, adding them to (and removing them from) the broadcaster.
package discovery
import (
"log"
"net"
"os"
"strings"
"sync"
"time"
"github.com/hashicorp/mdns"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/peer"
)
const (
serviceName = "_clip-sync._tcp"
domain = "local"
)
// Discovery manages mDNS advertisement and browsing.
type Discovery interface {
Start() error
Stop()
}
type mdnsDiscovery struct {
origin string
port int
broadcaster *peer.Broadcaster
mu sync.Mutex
server *mdns.Server
stopCh chan struct{}
doneCh chan struct{}
peers map[string]string // addr -> host
}
// New creates an mDNS discovery component that updates the given broadcaster.
func New(origin string, port int, b *peer.Broadcaster) Discovery {
return &mdnsDiscovery{
origin: origin,
port: port,
broadcaster: b,
peers: make(map[string]string),
}
}
func (d *mdnsDiscovery) Start() error {
host, _ := os.Hostname()
instance := "clip-sync-" + sanitize(host)
// Advertise this instance. Zero-value domain/host/ips are inferred by the
// library from the operating system.
service, err := mdns.NewMDNSService(instance, serviceName, "", "", d.port, nil, nil)
if err != nil {
return err
}
server, err := mdns.NewServer(&mdns.Config{Zone: service})
if err != nil {
return err
}
d.mu.Lock()
d.server = server
d.stopCh = make(chan struct{})
d.doneCh = make(chan struct{})
d.mu.Unlock()
go d.browseLoop()
log.Printf("discovery: mDNS advertised as %q, browsing for peers", instance)
return nil
}
func (d *mdnsDiscovery) Stop() {
d.mu.Lock()
stopCh := d.stopCh
server := d.server
d.mu.Unlock()
if stopCh != nil {
close(stopCh)
}
if server != nil {
_ = server.Shutdown()
}
if d.doneCh != nil {
<-d.doneCh
}
}
func (d *mdnsDiscovery) browseLoop() {
defer close(d.doneCh)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
d.browse()
for {
select {
case <-d.stopCh:
return
case <-ticker.C:
d.browse()
}
}
}
func (d *mdnsDiscovery) browse() {
entries := make(chan *mdns.ServiceEntry, 16)
params := &mdns.QueryParam{
Service: serviceName,
Domain: domain,
Timeout: 5 * time.Second,
Entries: entries,
}
seen := make(map[string]string)
go func() {
_ = mdns.Query(params)
close(entries)
}()
for entry := range entries {
host := strings.TrimSuffix(entry.Host, ".local.")
host = strings.TrimSuffix(host, ".")
// Skip ourselves (and our sanitized advertisement form).
if host == d.origin || host == sanitize(d.origin) {
continue
}
ip := entry.AddrV4
if ip == nil {
ip = entry.AddrV6
}
if ip == nil {
continue
}
addr := net.JoinHostPort(ip.String(), itoa(entry.Port))
seen[addr] = host
d.broadcaster.AddPeer(config.PeerConfig{Name: host, Addr: addr})
}
// Remove peers that disappeared since the last browse.
d.mu.Lock()
for addr := range d.peers {
if _, ok := seen[addr]; !ok {
d.broadcaster.RemovePeer(addr)
}
}
d.peers = seen
d.mu.Unlock()
}
func sanitize(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-':
return r
default:
return '-'
}
}, s)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+103
View File
@@ -0,0 +1,103 @@
// Package history provides a persistent, size-bounded clipboard history store.
//
// Entries are kept in memory as a ring buffer and flushed to a JSON file so the
// `clip-sync history` command can read them even when the daemon is not running.
package history
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// Entry is a single clipboard history record.
type Entry struct {
Text string `json:"text"`
Origin string `json:"origin"`
Ts int64 `json:"ts"` // nanosecond timestamp
}
// Store is a thread-safe, persistent history store.
type Store struct {
mu sync.Mutex
path string
max int
entries []Entry
}
// New creates a history store that persists to the given file path, keeping at
// most max entries. If max <= 0, a sensible default is used.
func New(path string, max int) *Store {
if max <= 0 {
max = 50
}
s := &Store{path: path, max: max}
s.load()
return s
}
// Append adds an entry, trimming the store to max entries and persisting it.
func (s *Store) Append(e Entry) {
if e.Text == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
// Skip exact duplicates of the most recent entry.
if len(s.entries) > 0 && s.entries[len(s.entries)-1].Text == e.Text {
return
}
s.entries = append(s.entries, e)
if len(s.entries) > s.max {
s.entries = s.entries[len(s.entries)-s.max:]
}
s.save()
}
// List returns a copy of the history, oldest first.
func (s *Store) List() []Entry {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Entry, len(s.entries))
copy(out, s.entries)
return out
}
func (s *Store) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return
}
_ = json.Unmarshal(data, &s.entries)
if len(s.entries) > s.max {
s.entries = s.entries[len(s.entries)-s.max:]
}
}
func (s *Store) save() {
data, err := json.MarshalIndent(s.entries, "", " ")
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return
}
_ = os.Rename(tmp, s.path)
}
// DefaultPath returns the default history file location.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("history: cannot find home directory: %w", err)
}
return filepath.Join(home, ".config", "clip-sync", "history.json"), nil
}
+74
View File
@@ -0,0 +1,74 @@
package history
import (
"path/filepath"
"testing"
)
func TestAppendAndList(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.json")
s := New(path, 10)
s.Append(Entry{Text: "first", Origin: "a", Ts: 1})
s.Append(Entry{Text: "second", Origin: "b", Ts: 2})
got := s.List()
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].Text != "first" || got[1].Text != "second" {
t.Errorf("order wrong: %+v", got)
}
}
func TestAppendSkipsDuplicate(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.json")
s := New(path, 10)
s.Append(Entry{Text: "dup", Origin: "a", Ts: 1})
s.Append(Entry{Text: "dup", Origin: "a", Ts: 2})
if len(s.List()) != 1 {
t.Errorf("len = %d, want 1 (duplicate skipped)", len(s.List()))
}
}
func TestTrimToMax(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.json")
s := New(path, 3)
for i := 0; i < 10; i++ {
s.Append(Entry{Text: string(rune('a' + i)), Origin: "a", Ts: int64(i)})
}
got := s.List()
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
if got[0].Text != "h" {
t.Errorf("oldest kept = %q, want \"h\"", got[0].Text)
}
}
func TestPersistence(t *testing.T) {
path := filepath.Join(t.TempDir(), "history.json")
s := New(path, 10)
s.Append(Entry{Text: "persisted", Origin: "a", Ts: 42})
// A new store reading the same path should reload the entry.
s2 := New(path, 10)
got := s2.List()
if len(got) != 1 || got[0].Text != "persisted" {
t.Errorf("reloaded = %+v, want persisted entry", got)
}
}
func TestDefaultPath(t *testing.T) {
p, err := DefaultPath()
if err != nil {
t.Fatalf("DefaultPath: %v", err)
}
if p == "" {
t.Error("DefaultPath returned empty string")
}
}
+11
View File
@@ -0,0 +1,11 @@
// Package notify provides best-effort desktop notifications.
//
// Each platform file defines its own implementation using //go:build tags:
// Linux uses notify-send, macOS uses osascript, Windows is a no-op (Windows
// toast notifications require a registered app or a third-party module).
package notify
// Notifier sends a desktop notification.
type Notifier interface {
Notify(title, body string) error
}
+29
View File
@@ -0,0 +1,29 @@
//go:build darwin
package notify
import "os/exec"
// Notifier uses osascript (Notification Center) on macOS.
type notifier struct{}
// New returns a macOS notifier using osascript.
func New() Notifier { return notifier{} }
func (notifier) Notify(title, body string) error {
script := "display notification " + quote(body) + " with title " + quote(title)
return exec.Command("osascript", "-e", script).Run()
}
func quote(s string) string {
out := `"`
for _, r := range s {
switch r {
case '\\', '"':
out += "\\" + string(r)
default:
out += string(r)
}
}
return out + `"`
}
+15
View File
@@ -0,0 +1,15 @@
//go:build linux
package notify
import "os/exec"
// Notifier uses notify-send (libnotify) on Linux.
type notifier struct{}
// New returns a Linux notifier using notify-send.
func New() Notifier { return notifier{} }
func (notifier) Notify(title, body string) error {
return exec.Command("notify-send", title, body).Run()
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !linux && !darwin && !windows
package notify
// Notifier is a no-op on unsupported platforms.
type notifier struct{}
// New returns a no-op notifier.
func New() Notifier { return notifier{} }
func (notifier) Notify(string, string) error { return nil }
+13
View File
@@ -0,0 +1,13 @@
//go:build windows
package notify
// Notifier is a no-op on Windows. Native toast notifications require a
// registered, packaged app (or a third-party module such as BurntToast), which
// is out of scope for a single self-contained binary.
type notifier struct{}
// New returns a no-op Windows notifier.
func New() Notifier { return notifier{} }
func (notifier) Notify(string, string) error { return nil }
+168 -17
View File
@@ -1,68 +1,216 @@
// Package peer sends clipboard content to remote clip-sync peers via HTTP POST.
// Package peer sends clipboard content to remote clip-sync peers via HTTP(S) POST.
package peer
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"sync"
"time"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
"git.dracodev.net/Projets/clip-sync/internal/transfer"
)
const clipPath = "/clip"
const (
clipPath = "/clip"
filePath = "/file"
)
// Options configures peer transport security.
type Options struct {
SharedKey string
TLS bool // global TLS default
CertFile string // CA (self-signed cert) to trust
InsecureSkipVerify bool
}
// Broadcaster sends clip payloads to all configured peers concurrently.
type Broadcaster struct {
mu sync.RWMutex
peers []config.PeerConfig
origin string
client *http.Client
opts Options
}
// NewBroadcaster creates a Broadcaster for the configured peer list.
func NewBroadcaster(peers []config.PeerConfig, origin string) *Broadcaster {
func NewBroadcaster(peers []config.PeerConfig, origin string, opts Options) *Broadcaster {
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
}).DialContext,
MaxIdleConnsPerHost: 2,
TLSClientConfig: buildTLSConfig(opts),
},
}
return &Broadcaster{
peers: peers,
origin: origin,
client: &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
}).DialContext,
MaxIdleConnsPerHost: 2,
},
},
client: client,
opts: opts,
}
}
func buildTLSConfig(opts Options) *tls.Config {
if !opts.TLS {
return nil
}
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: opts.InsecureSkipVerify, // #nosec G402 — opt-in LAN trust
}
if !opts.InsecureSkipVerify && opts.CertFile != "" {
if pem, err := os.ReadFile(opts.CertFile); err == nil {
pool := x509.NewCertPool()
if pool.AppendCertsFromPEM(pem) {
cfg.RootCAs = pool
}
}
}
return cfg
}
// Broadcast sends the given text to all configured peers in parallel.
// Errors are logged but not returned — a single unreachable peer should not
// block other peers or the daemon loop.
func (b *Broadcaster) Broadcast(text string) {
body, ok := b.marshalPayload(text)
if !ok {
return
}
snap := b.snapshot()
for i := range snap {
go b.sendToPeer(snap[i], body)
}
}
// BroadcastSync sends the given text to all peers and blocks until every send
// has completed (or failed). Used by the one-shot mode so the process does not
// exit before the payload is on the wire.
func (b *Broadcaster) BroadcastSync(text string) {
body, ok := b.marshalPayload(text)
if !ok {
return
}
snap := b.snapshot()
var wg sync.WaitGroup
for i := range snap {
wg.Add(1)
go func(p config.PeerConfig) {
defer wg.Done()
b.sendToPeer(p, body)
}(snap[i])
}
wg.Wait()
}
func (b *Broadcaster) marshalPayload(text string) ([]byte, bool) {
payload := dedup.Payload{
Text: text,
Ts: time.Now().UnixNano(),
Origin: b.origin,
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("peer: marshal payload: %v", err)
return nil, false
}
return body, true
}
// snapshot returns a copy of the current peer list under read lock.
func (b *Broadcaster) snapshot() []config.PeerConfig {
b.mu.RLock()
defer b.mu.RUnlock()
return append([]config.PeerConfig(nil), b.peers...)
}
// PeerCount returns the number of currently configured peers.
func (b *Broadcaster) PeerCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.peers)
}
// AddPeer adds a peer if its address is not already present.
func (b *Broadcaster) AddPeer(p config.PeerConfig) {
b.mu.Lock()
defer b.mu.Unlock()
for _, existing := range b.peers {
if existing.Addr == p.Addr {
return
}
}
b.peers = append(b.peers, p)
}
// RemovePeer removes a peer by address.
func (b *Broadcaster) RemovePeer(addr string) {
b.mu.Lock()
defer b.mu.Unlock()
out := b.peers[:0]
for _, p := range b.peers {
if p.Addr != addr {
out = append(out, p)
}
}
b.peers = out
}
// SendFile sends a binary payload to all configured peers in parallel.
func (b *Broadcaster) SendFile(f transfer.File) {
body, err := json.Marshal(f)
if err != nil {
log.Printf("peer: marshal file: %v", err)
return
}
for i := range b.peers {
go b.sendToPeer(b.peers[i], body)
snap := b.snapshot()
for i := range snap {
go b.sendToPath(snap[i], filePath, body)
}
}
// SendFileSync sends a binary payload to all peers and blocks until done.
func (b *Broadcaster) SendFileSync(f transfer.File) {
body, err := json.Marshal(f)
if err != nil {
log.Printf("peer: marshal file: %v", err)
return
}
snap := b.snapshot()
var wg sync.WaitGroup
for i := range snap {
wg.Add(1)
go func(p config.PeerConfig) {
defer wg.Done()
b.sendToPath(p, filePath, body)
}(snap[i])
}
wg.Wait()
}
func (b *Broadcaster) sendToPeer(p config.PeerConfig, body []byte) {
url := fmt.Sprintf("http://%s%s", p.Addr, clipPath)
b.sendToPath(p, clipPath, body)
}
func (b *Broadcaster) sendToPath(p config.PeerConfig, path string, body []byte) {
scheme := "http"
if p.TLS || b.opts.TLS {
scheme = "https"
}
url := fmt.Sprintf("%s://%s%s", scheme, p.Addr, path)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -70,6 +218,9 @@ func (b *Broadcaster) sendToPeer(p config.PeerConfig, body []byte) {
return
}
req.Header.Set("Content-Type", "application/json")
if b.opts.SharedKey != "" {
req.Header.Set("Authorization", "Bearer "+b.opts.SharedKey)
}
resp, err := b.client.Do(req)
if err != nil {
+2 -2
View File
@@ -36,7 +36,7 @@ func TestBroadcastSendsPayload(t *testing.T) {
{Name: "test-peer", Addr: ts.Listener.Addr().String()},
}
b := NewBroadcaster(peers, "my-machine")
b := NewBroadcaster(peers, "my-machine", Options{})
b.Broadcast("hello, peer!")
select {
@@ -57,7 +57,7 @@ func TestBroadcastSendsPayload(t *testing.T) {
func TestBroadcastEmptyPeers(t *testing.T) {
// Should not panic or error with zero peers.
b := NewBroadcaster(nil, "my-machine")
b := NewBroadcaster(nil, "my-machine", Options{})
b.Broadcast("test")
b.Broadcast("")
}
+94
View File
@@ -0,0 +1,94 @@
// Package security provides shared-key and TLS certificate generation for
// clip-sync peers. It uses only the Go standard library.
package security
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
)
// GenerateKey returns a random 256-bit shared key, hex-encoded. It is suitable
// for use as the security.shared_key bearer token shared by all peers.
func GenerateKey() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate key: %w", err)
}
return hex.EncodeToString(buf), nil
}
// GenerateSelfSignedCert creates a self-signed ECDSA certificate and writes it
// (PEM-encoded) to certFile and keyFile. It is valid for the given hosts/IPs
// and 10 years, which is acceptable for a LAN trust model where all peers
// share the same certificate and key files.
func GenerateSelfSignedCert(certFile, keyFile string, hosts []string) error {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate cert: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("generate cert serial: %w", err)
}
now := time.Now()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "clip-sync"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
} else {
tmpl.DNSNames = append(tmpl.DNSNames, h)
}
}
if len(tmpl.IPAddresses) == 0 && len(tmpl.DNSNames) == 0 {
tmpl.DNSNames = []string{"localhost"}
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return fmt.Errorf("generate cert: create: %w", err)
}
certOut, err := os.Create(certFile)
if err != nil {
return fmt.Errorf("generate cert: %w", err)
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
return fmt.Errorf("generate cert: encode: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return fmt.Errorf("generate cert: marshal key: %w", err)
}
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("generate cert: %w", err)
}
defer keyOut.Close()
if err := pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
return fmt.Errorf("generate cert: encode key: %w", err)
}
return nil
}
+44
View File
@@ -0,0 +1,44 @@
package security
import (
"os"
"path/filepath"
"testing"
)
func TestGenerateKey(t *testing.T) {
k1, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
k2, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if len(k1) != 64 {
t.Errorf("key length = %d, want 64 hex chars", len(k1))
}
if k1 == k2 {
t.Error("two generated keys are identical")
}
}
func TestGenerateSelfSignedCert(t *testing.T) {
dir := t.TempDir()
certFile := filepath.Join(dir, "cert.pem")
keyFile := filepath.Join(dir, "key.pem")
if err := GenerateSelfSignedCert(certFile, keyFile, []string{"localhost", "127.0.0.1"}); err != nil {
t.Fatalf("GenerateSelfSignedCert: %v", err)
}
for _, f := range []string{certFile, keyFile} {
info, err := os.Stat(f)
if err != nil {
t.Fatalf("Stat %s: %v", f, err)
}
if info.Size() == 0 {
t.Errorf("%s is empty", f)
}
}
}
+259 -4
View File
@@ -1,35 +1,77 @@
// Package server provides the HTTP endpoint that receives clipboard payloads
// from remote clip-sync peers.
// from remote clip-sync peers, plus a small local monitoring UI.
package server
import (
"crypto/subtle"
"encoding/json"
"log"
"net/http"
"sync"
"time"
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
"git.dracodev.net/Projets/clip-sync/internal/history"
"git.dracodev.net/Projets/clip-sync/internal/notify"
"git.dracodev.net/Projets/clip-sync/internal/transfer"
)
// Options configures a Server.
type Options struct {
Addr string
SharedKey string
AllowedOrigins []string
MaxBodyBytes int64
History *history.Store
Notifier notify.Notifier
ReceiveFiles bool
ReceiveDir string
}
// Stats holds lightweight counters exposed via the monitoring UI.
type Stats struct {
StartedAt time.Time `json:"started_at"`
ReceivedCount int64 `json:"received_count"`
LastReceivedAt time.Time `json:"last_received_at"`
LastReceivedBy string `json:"last_received_by"`
LastReceivedLen int `json:"last_received_len"`
}
// Server receives clips from peers and writes them to the local clipboard.
type Server struct {
httpServer *http.Server
clipboard clipboard.Clipboard
filter *dedup.Filter
opts Options
allowed map[string]struct{}
mu sync.Mutex
stats Stats
}
// New creates a Server that listens on addr.
func New(addr string, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
// New creates a Server that listens on opts.Addr.
func New(opts Options, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
s := &Server{
clipboard: cb,
filter: filter,
opts: opts,
allowed: make(map[string]struct{}),
stats: Stats{StartedAt: time.Now()},
}
for _, o := range opts.AllowedOrigins {
s.allowed[o] = struct{}{}
}
mux := http.NewServeMux()
mux.HandleFunc("/clip", s.handleClip)
mux.HandleFunc("/file", s.handleFile)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/history", s.handleHistory)
mux.HandleFunc("/", s.handleIndex)
s.httpServer = &http.Server{
Addr: addr,
Addr: opts.Addr,
Handler: mux,
}
@@ -41,17 +83,36 @@ func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
// ListenAndServeTLS starts the HTTPS server with the given cert/key files.
func (s *Server) ListenAndServeTLS(certFile, keyFile string) error {
return s.httpServer.ListenAndServeTLS(certFile, keyFile)
}
// Close gracefully shuts down the HTTP server.
func (s *Server) Close() error {
return s.httpServer.Close()
}
// Stats returns a snapshot of the server statistics.
func (s *Server) Stats() Stats {
s.mu.Lock()
defer s.mu.Unlock()
return s.stats
}
func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.authorized(w, r) {
return
}
// Bound the request body to prevent memory-exhaustion / DoS.
r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes)
var p dedup.Payload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "bad request: invalid JSON", http.StatusBadRequest)
@@ -63,6 +124,14 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
return
}
// Origin validation: reject payloads from unknown origins if configured.
if len(s.allowed) > 0 {
if _, ok := s.allowed[p.Origin]; !ok {
http.Error(w, "forbidden: unknown origin", http.StatusForbidden)
return
}
}
if s.filter.ShouldIgnore(p) {
w.WriteHeader(http.StatusNoContent)
return
@@ -75,5 +144,191 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
}
s.filter.MarkWritten(p.Text)
s.recordReceive(p)
if s.opts.History != nil {
s.opts.History.Append(history.Entry{Text: p.Text, Origin: p.Origin, Ts: p.Ts})
}
if s.opts.Notifier != nil {
if err := s.opts.Notifier.Notify("clip-sync", "Clip reçu de "+p.Origin); err != nil {
log.Printf("server: notify: %v", err)
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) recordReceive(p dedup.Payload) {
s.mu.Lock()
defer s.mu.Unlock()
s.stats.ReceivedCount++
s.stats.LastReceivedAt = time.Now()
s.stats.LastReceivedBy = p.Origin
s.stats.LastReceivedLen = len(p.Text)
}
// authorized checks the shared-key bearer token (constant-time comparison).
// It writes the error response and returns false when authentication fails.
func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool {
if s.opts.SharedKey == "" {
return true
}
got := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(got) < len(prefix) || !equalFoldSubtle(got[:len(prefix)], prefix) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false
}
token := got[len(prefix):]
if subtle.ConstantTimeCompare([]byte(token), []byte(s.opts.SharedKey)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false
}
return true
}
// handleFile receives a binary payload and, if enabled, writes it to disk.
func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.authorized(w, r) {
return
}
if !s.opts.ReceiveFiles {
http.Error(w, "file reception disabled", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes)
var f transfer.File
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
http.Error(w, "bad request: invalid JSON", http.StatusBadRequest)
return
}
if f.Data == "" || f.Name == "" {
http.Error(w, "bad request: missing name or data", http.StatusBadRequest)
return
}
if len(s.allowed) > 0 {
if _, ok := s.allowed[f.Origin]; !ok {
http.Error(w, "forbidden: unknown origin", http.StatusForbidden)
return
}
}
dest, err := f.SaveTo(s.opts.ReceiveDir)
if err != nil {
log.Printf("server: file save: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
log.Printf("server: received file %q from %q -> %s", f.Name, f.Origin, dest)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"stats": s.Stats(),
})
}
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if s.opts.History == nil {
_ = json.NewEncoder(w).Encode([]history.Entry{})
return
}
_ = json.NewEncoder(w).Encode(s.opts.History.List())
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(indexHTML))
}
// equalFoldSubtle is a constant-time ASCII case-insensitive comparison. It is
// only used to match the "Bearer " scheme prefix, so a length difference is
// acceptable to leak.
func equalFoldSubtle(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if ca >= 'A' && ca <= 'Z' {
ca += 'a' - 'A'
}
if cb >= 'A' && cb <= 'Z' {
cb += 'a' - 'A'
}
if ca != cb {
return false
}
}
return true
}
const indexHTML = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>clip-sync</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
h1 { font-size: 1.4rem; }
.card { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.kv { display: flex; justify-content: space-between; padding: .2rem 0; }
ul { list-style: none; padding: 0; margin: 0; }
li { padding: .35rem .5rem; border-bottom: 1px solid #eaeef2; font-family: monospace; font-size: .85rem; word-break: break-all; }
.origin { color: #0969da; font-weight: 600; }
</style>
</head>
<body>
<h1>clip-sync — monitoring</h1>
<div class="card">
<div class="kv"><span>État</span><strong id="status">…</strong></div>
<div class="kv"><span>Clips reçus</span><span id="received">…</span></div>
<div class="kv"><span>Dernier clip</span><span id="last">…</span></div>
</div>
<div class="card">
<h2>Historique récent</h2>
<ul id="history"><li>Chargement…</li></ul>
</div>
<script>
async function refresh() {
try {
const h = await (await fetch('/health')).json();
document.getElementById('status').textContent = h.ok ? 'OK' : 'ERREUR';
document.getElementById('received').textContent = h.stats.received_count;
document.getElementById('last').textContent = h.stats.last_received_by
? (h.stats.last_received_by + ' (' + h.stats.last_received_len + ' octets)') : '—';
} catch (e) { document.getElementById('status').textContent = 'indisponible'; }
try {
const entries = await (await fetch('/history')).json();
const ul = document.getElementById('history');
ul.innerHTML = '';
[...entries].reverse().slice(0, 20).forEach(e => {
const li = document.createElement('li');
li.innerHTML = '<span class="origin">' + escapeHtml(e.origin || 'local') + '</span> ' + escapeHtml(e.text);
ul.appendChild(li);
});
} catch (e) {}
}
function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>`
+121 -52
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
@@ -16,29 +17,37 @@ type mockClipboard struct {
text string
}
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
func (m *mockClipboard) Write(s string) error { m.text = s; return nil }
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
func (m *mockClipboard) Write(s string) error { m.text = s; return nil }
func TestHandleClipAcceptsValidPayload(t *testing.T) {
cb := &mockClipboard{}
func newTestServer(cb *mockClipboard, opts Options) *Server {
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
return New(opts, cb, filter)
}
payload := dedup.Payload{
Text: "test text",
Ts: 1690000000000000000,
Origin: "other-machine",
}
func doClip(t *testing.T, s *Server, payload dedup.Payload, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
s.handleClip(w, req)
return w
}
srv.handleClip(w, req)
func TestHandleClipAcceptsValidPayload(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
w := doClip(t, srv, dedup.Payload{
Text: "test text", Ts: 1690000000000000000, Origin: "other-machine",
}, nil)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
@@ -50,32 +59,18 @@ func TestHandleClipAcceptsValidPayload(t *testing.T) {
func TestHandleClipIgnoresEcho(t *testing.T) {
cb := &mockClipboard{text: "existing"}
filter := dedup.NewFilter("my-machine")
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
// Simulate we just wrote "echo text" locally.
filter.MarkWritten("echo text")
srv.filter.MarkWritten("echo text")
srv := New(":0", cb, filter)
payload := dedup.Payload{
Text: "echo text", // same text we just wrote
Ts: 1690000000000000000,
Origin: "other-machine",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.handleClip(w, req)
w := doClip(t, srv, dedup.Payload{
Text: "echo text", Ts: 1690000000000000000, Origin: "other-machine",
}, nil)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
// Clipboard should NOT be overwritten.
if cb.text != "existing" {
t.Errorf("clipboard = %q, want \"existing\" (should not be overwritten)", cb.text)
}
@@ -83,8 +78,7 @@ func TestHandleClipIgnoresEcho(t *testing.T) {
func TestHandleClipRejectsInvalidMethod(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
req := httptest.NewRequest(http.MethodGet, "/clip", nil)
w := httptest.NewRecorder()
@@ -98,23 +92,11 @@ func TestHandleClipRejectsInvalidMethod(t *testing.T) {
func TestHandleClipRejectsEmptyText(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
payload := dedup.Payload{
Text: "",
Ts: 1690000000000000000,
Origin: "other-machine",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.handleClip(w, req)
w := doClip(t, srv, dedup.Payload{
Text: "", Ts: 1690000000000000000, Origin: "other-machine",
}, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
@@ -123,8 +105,7 @@ func TestHandleClipRejectsEmptyText(t *testing.T) {
func TestHandleClipRejectsInvalidJSON(t *testing.T) {
cb := &mockClipboard{}
filter := dedup.NewFilter("my-machine")
srv := New(":0", cb, filter)
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader([]byte("not json")))
w := httptest.NewRecorder()
@@ -136,7 +117,95 @@ func TestHandleClipRejectsInvalidJSON(t *testing.T) {
}
}
func TestHandleClipRequiresSharedKey(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, SharedKey: "secret"})
// No auth header → unauthorized.
w := doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "other-machine"}, nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d (missing key)", w.Code, http.StatusUnauthorized)
}
// Wrong key → unauthorized.
w = doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "other-machine"},
map[string]string{"Authorization": "Bearer wrong"})
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d (wrong key)", w.Code, http.StatusUnauthorized)
}
// Correct key → accepted.
w = doClip(t, srv, dedup.Payload{Text: "x", Ts: 2, Origin: "other-machine"},
map[string]string{"Authorization": "Bearer secret"})
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d (correct key)", w.Code, http.StatusNoContent)
}
}
func TestHandleClipRejectsUnknownOrigin(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, AllowedOrigins: []string{"trusted-host"}})
w := doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "evil-host"}, nil)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestHandleClipEnforcesBodyLimit(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 16})
req := httptest.NewRequest(http.MethodPost, "/clip", strings.NewReader(`{"text":"`+strings.Repeat("a", 1024)+`"}`))
w := httptest.NewRecorder()
srv.handleClip(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d (oversized body)", w.Code, http.StatusBadRequest)
}
}
func TestHandleFileReception(t *testing.T) {
cb := &mockClipboard{}
dir := t.TempDir()
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, ReceiveFiles: true, ReceiveDir: dir})
body := `{"name":"hello.txt","mime":"text/plain","data":"aGVsbG8=","ts":1,"origin":"other-machine"}`
req := httptest.NewRequest(http.MethodPost, "/file", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleFile(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) == 0 {
t.Fatalf("expected a file in %s, got %d entries (err=%v)", dir, len(entries), err)
}
data, _ := os.ReadFile(dir + "/" + entries[0].Name())
if string(data) != "hello" {
t.Errorf("file content = %q, want \"hello\"", data)
}
}
func TestHandleFileDisabled(t *testing.T) {
cb := &mockClipboard{}
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) // ReceiveFiles defaults false
body := `{"name":"x","data":"eA==","ts":1,"origin":"other-machine"}`
req := httptest.NewRequest(http.MethodPost, "/file", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleFile(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestMain(m *testing.M) {
// Ensure clipboard.New() won't be called in test (mock is used directly).
os.Exit(m.Run())
}
+83
View File
@@ -0,0 +1,83 @@
// Package transfer handles binary content (images, files) transfer between
// clip-sync peers. It is kept separate from the text clipboard path so that
// text sync remains simple and safe; binary content is sent explicitly and is
// written to disk on the receiving side only when enabled.
package transfer
import (
"encoding/base64"
"fmt"
"mime"
"os"
"path/filepath"
"time"
)
// File is the wire payload for a transferred binary object.
type File struct {
Name string `json:"name"`
Mime string `json:"mime"`
Data string `json:"data"` // base64-encoded content
Ts int64 `json:"ts"`
Origin string `json:"origin"`
}
// ReadFile reads a file from disk and encodes it as a transfer.File payload.
func ReadFile(path, origin string) (*File, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
mimeType := mime.TypeByExtension(filepath.Ext(path))
if mimeType == "" {
mimeType = "application/octet-stream"
}
return &File{
Name: filepath.Base(path),
Mime: mimeType,
Data: base64.StdEncoding.EncodeToString(data),
Ts: time.Now().UnixNano(),
Origin: origin,
}, nil
}
// Decode returns the raw bytes of the file.
func (f *File) Decode() ([]byte, error) {
if f.Data == "" {
return nil, fmt.Errorf("transfer: empty data")
}
return base64.StdEncoding.DecodeString(f.Data)
}
// SaveTo writes the decoded content to the given directory, sanitizing the
// file name to avoid path traversal. It returns the written path.
func (f *File) SaveTo(dir string) (string, error) {
data, err := f.Decode()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
name := filepath.Base(f.Name)
if name == "." || name == "" || name == string(filepath.Separator) {
name = "received.bin"
}
// Avoid overwriting existing files: append a numeric suffix if needed.
dest := filepath.Join(dir, name)
for i := 1; ; i++ {
if _, err := os.Stat(dest); os.IsNotExist(err) {
break
}
ext := filepath.Ext(name)
base := name[:len(name)-len(ext)]
dest = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
}
return dest, os.WriteFile(dest, data, 0644)
}
+83
View File
@@ -0,0 +1,83 @@
package transfer
import (
"os"
"path/filepath"
"testing"
)
func TestReadFileRoundTrip(t *testing.T) {
src := filepath.Join(t.TempDir(), "hello.txt")
content := []byte("hello, clip-sync!")
if err := os.WriteFile(src, content, 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
f, err := ReadFile(src, "machine-a")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if f.Name != "hello.txt" {
t.Errorf("Name = %q, want \"hello.txt\"", f.Name)
}
if f.Mime != "text/plain; charset=utf-8" {
t.Errorf("Mime = %q, want \"text/plain; charset=utf-8\"", f.Mime)
}
decoded, err := f.Decode()
if err != nil {
t.Fatalf("Decode: %v", err)
}
if string(decoded) != string(content) {
t.Errorf("decoded = %q, want %q", decoded, content)
}
}
func TestSaveTo(t *testing.T) {
dir := t.TempDir()
f := &File{Name: "out.bin", Mime: "application/octet-stream", Data: ""}
// Data must be base64-encoded; use a known encoding of "abc".
f.Data = "YWJj"
dest, err := f.SaveTo(dir)
if err != nil {
t.Fatalf("SaveTo: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != "abc" {
t.Errorf("saved = %q, want \"abc\"", got)
}
}
func TestSaveToAvoidsOverwrite(t *testing.T) {
dir := t.TempDir()
f := &File{Name: "out.bin", Data: "eA=="} // base64 "x"
first, err := f.SaveTo(dir)
if err != nil {
t.Fatalf("first SaveTo: %v", err)
}
second, err := f.SaveTo(dir)
if err != nil {
t.Fatalf("second SaveTo: %v", err)
}
if first == second {
t.Errorf("second save overwrote first: %q == %q", first, second)
}
}
func TestSaveToSanitizesPath(t *testing.T) {
dir := t.TempDir()
f := &File{Name: "../../etc/passwd", Data: "eA=="}
dest, err := f.SaveTo(dir)
if err != nil {
t.Fatalf("SaveTo: %v", err)
}
if filepath.Dir(dest) != dir {
t.Errorf("dest dir = %q, want %q (path traversal not sanitized)", filepath.Dir(dest), dir)
}
}