feat: implement security, discovery, history, file transfer, web UI (P0-P3)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user