cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SaveDownload writes a received file's bytes into dir (empty → system Downloads),
|
||||
// returning the absolute path written. The name comes from a transfer peer (U2,
|
||||
// untrusted), so it is sanitized to a bare filename before use — no path
|
||||
// components survive, so a crafted name like "../../x" cannot escape dir. On a
|
||||
// name collision a " (n)" suffix is appended rather than overwriting. After a
|
||||
// successful write the file is best-effort revealed in the OS file manager.
|
||||
//
|
||||
// If the configured dir is unwritable (deleted, no permission, read-only), the
|
||||
// write is retried into the system Downloads dir before giving up — the WebView
|
||||
// has no usable download fallback (WKWebView ignores blob a[download]), so a
|
||||
// received file must never be silently lost to a bad setting. The returned path
|
||||
// reflects where the file actually landed, so the UI can show it.
|
||||
func SaveDownload(dir, name string, data []byte) (string, error) {
|
||||
if dir == "" {
|
||||
dir = DefaultDownloadDir()
|
||||
}
|
||||
path, err := writeInto(dir, name, data)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if def := DefaultDownloadDir(); def != dir {
|
||||
if p, ferr := writeInto(def, name, data); ferr == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", err // report the original (configured-dir) failure
|
||||
}
|
||||
|
||||
// writeInto sanitizes the name, resolves a non-colliding path under dir, writes
|
||||
// the bytes, and reveals the result. Returns the absolute path written.
|
||||
func writeInto(dir, name string, data []byte) (string, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
safe := sanitizeFileName(name)
|
||||
if safe == "" {
|
||||
safe = "cdrop-download"
|
||||
}
|
||||
target := uniquePath(dir, safe)
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
revealInFileManager(target)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// sanitizeFileName reduces a peer-supplied name to a single safe filename:
|
||||
// directory components are dropped (path-traversal defense), control chars are
|
||||
// removed, and characters reserved by common filesystems are replaced with "_".
|
||||
func sanitizeFileName(name string) string {
|
||||
name = filepath.Base(filepath.FromSlash(name)) // strip any directory parts
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "." || name == ".." {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r < 0x20:
|
||||
continue // control characters
|
||||
case strings.ContainsRune(`/\:*?"<>|`, r):
|
||||
b.WriteRune('_') // filesystem-reserved
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
if len(out) > 200 {
|
||||
out = strings.TrimSpace(out[:200])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uniquePath returns dir/name, or dir/"name (n).ext" with the lowest n that does
|
||||
// not yet exist, so a repeated transfer never silently overwrites a prior file.
|
||||
func uniquePath(dir, name string) string {
|
||||
target := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
return target
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
for i := 1; ; i += 1 {
|
||||
cand := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext))
|
||||
if _, err := os.Stat(cand); os.IsNotExist(err) {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revealInFileManager opens the OS file manager with the saved file selected.
|
||||
// Best-effort: the save already succeeded, so any error here is ignored.
|
||||
func revealInFileManager(path string) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
_ = exec.Command("open", "-R", path).Start()
|
||||
case "windows":
|
||||
_ = exec.Command("explorer", "/select,"+path).Start()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user