Compare commits

3 Commits

Author SHA1 Message Date
ae0ab09b48 add: .golangci.yml 2026-02-11 04:21:54 +08:00
a3989aeedd fix: discovery.peers resource competition problem 2026-02-10 23:37:00 +08:00
ea40aa76d0 feat: peercard accept drag event 2026-02-10 22:44:18 +08:00
18 changed files with 475 additions and 111 deletions

23
.golangci.yml Normal file
View File

@@ -0,0 +1,23 @@
version: "2"
linters:
default: standard
enable:
- staticcheck
- gosec
exclusions:
rules:
- linters:
- gosec
text: "G304:"
- linters:
- errcheck
text: "is not checked"
formatters:
enable:
- gofmt
- gofumpt
- goimports
- gci
- golines
output:
path-mode: abs

View File

@@ -2,5 +2,6 @@
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export { export {
File,
FilesDroppedEvent FilesDroppedEvent
} from "./models.js"; } from "./models.js";

View File

@@ -5,8 +5,33 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime"; import { Create as $Create } from "@wailsio/runtime";
export class File {
"name": string;
"path": string;
/** Creates a new File instance. */
constructor($$source: Partial<File> = {}) {
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("path" in $$source)) {
this["path"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new File instance from a string or object.
*/
static createFrom($$source: any = {}): File {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new File($$parsedSource as Partial<File>);
}
}
export class FilesDroppedEvent { export class FilesDroppedEvent {
"files": string[]; "files": File[];
"target": string; "target": string;
/** Creates a new FilesDroppedEvent instance. */ /** Creates a new FilesDroppedEvent instance. */
@@ -25,7 +50,7 @@ export class FilesDroppedEvent {
* Creates a new FilesDroppedEvent instance from a string or object. * Creates a new FilesDroppedEvent instance from a string or object.
*/ */
static createFrom($$source: any = {}): FilesDroppedEvent { static createFrom($$source: any = {}): FilesDroppedEvent {
const $$createField0_0 = $$createType0; const $$createField0_0 = $$createType1;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("files" in $$parsedSource) { if ("files" in $$parsedSource) {
$$parsedSource["files"] = $$createField0_0($$parsedSource["files"]); $$parsedSource["files"] = $$createField0_0($$parsedSource["files"]);
@@ -35,4 +60,5 @@ export class FilesDroppedEvent {
} }
// Private type creation functions // Private type creation functions
const $$createType0 = $Create.Array($Create.Any); const $$createType0 = File.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

@@ -150,7 +150,18 @@ const handleCleanFinished = async () => {
<v-container fluid class="pa-4"> <v-container fluid class="pa-4">
<!-- 发现视图 --> <!-- 发现视图 -->
<div v-show="activeKey === 'discover'"> <div v-show="activeKey === 'discover'">
<div v-if="peers.length > 0" class="peer-grid"> <div v-if="peers.length > 0">
<v-alert
icon="mdi-information-outline"
density="compact"
variant="tonal"
color="primary"
class="mb-4 text-body-2"
closable
>
{{ t("discover.dragDropHint") }}
</v-alert>
<div class="peer-grid">
<div v-for="peer in peers" :key="peer.id"> <div v-for="peer in peers" :key="peer.id">
<PeerCard <PeerCard
:peer="peer" :peer="peer"
@@ -158,6 +169,7 @@ const handleCleanFinished = async () => {
/> />
</div> </div>
</div> </div>
</div>
<div <div
v-else v-else

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
// --- Vue 核心 --- // --- Vue 核心 ---
import { computed, ref, watch, onMounted } from "vue"; import { computed, ref, watch, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
// --- 组件 --- // --- 组件 ---
@@ -8,7 +8,7 @@ import FileSendModal from "./modals/FileSendModal.vue";
import TextSendModal from "./modals/TextSendModal.vue"; import TextSendModal from "./modals/TextSendModal.vue";
// --- Wails & 后端绑定 --- // --- Wails & 后端绑定 ---
import { Dialogs, Clipboard } from "@wailsio/runtime"; import { Dialogs, Clipboard, Events } from "@wailsio/runtime";
import { import {
SendFolder, SendFolder,
SendText, SendText,
@@ -19,14 +19,24 @@ import {
AddTrust, AddTrust,
RemoveTrust, RemoveTrust,
} from "../../bindings/mesh-drop/internal/config/config"; } from "../../bindings/mesh-drop/internal/config/config";
import { File } from "bindings/mesh-drop/models";
// --- 生命周期 --- // --- 生命周期 ---
const droppedFiles = ref<File[]>([]);
onMounted(async () => { onMounted(async () => {
try { try {
isTrusted.value = await IsTrusted(props.peer.id); isTrusted.value = await IsTrusted(props.peer.id);
} catch (err) { } catch (err) {
console.error("Failed to check trusted peer status:", err); console.error("Failed to check trusted peer status:", err);
} }
Events.On("files-dropped", (event) => {
droppedFiles.value = event.data.files;
showFileModal.value = true;
});
});
onUnmounted(() => {
Events.Off("files-dropped");
}); });
// --- 属性 & 事件 --- // --- 属性 & 事件 ---
@@ -171,7 +181,14 @@ const handleUntrust = () => {
</script> </script>
<template> <template>
<v-card hover link class="peer-card pa-2" :ripple="false"> <v-card
hover
link
class="peer-card pa-2"
:ripple="false"
data-file-drop-target
:id="`drop-zone-peer-${peer.id}`"
>
<template #title> <template #title>
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-icon :icon="osIcon" size="24" class="mr-2"></v-icon> <v-icon :icon="osIcon" size="24" class="mr-2"></v-icon>
@@ -219,6 +236,16 @@ const handleUntrust = () => {
{{ t("discover.noRoute") }} {{ t("discover.noRoute") }}
</v-chip> </v-chip>
</div> </div>
<!-- 拖放提示覆盖层 -->
<div class="drag-drop-overlay">
<v-icon
icon="mdi-file-upload-outline"
size="48"
color="primary"
style="opacity: 0.8"
></v-icon>
</div>
</template> </template>
<v-card-actions> <v-card-actions>
@@ -304,6 +331,7 @@ const handleUntrust = () => {
v-model="showFileModal" v-model="showFileModal"
:peer="peer" :peer="peer"
:selectedIp="selectedIp" :selectedIp="selectedIp"
:files="droppedFiles"
@transferStarted="emit('transferStarted')" @transferStarted="emit('transferStarted')"
/> />
@@ -314,3 +342,70 @@ const handleUntrust = () => {
@transferStarted="emit('transferStarted')" @transferStarted="emit('transferStarted')"
/> />
</template> </template>
<style scoped>
.peer-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.peer-card::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgb(var(--v-theme-primary));
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 1;
}
.peer-card.file-drop-target-active {
transform: translateY(-4px);
box-shadow: 0 8px 24px -4px rgba(var(--v-theme-primary), 0.24) !important;
border-color: rgb(var(--v-theme-primary)) !important;
}
.peer-card.file-drop-target-active::after {
opacity: 0.12;
}
.drag-drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
background: rgba(var(--v-theme-surface), 0.8);
backdrop-filter: blur(2px);
}
.peer-card.file-drop-target-active .drag-drop-overlay {
opacity: 1;
}
.drag-drop-content {
color: rgb(var(--v-theme-primary));
font-weight: 500;
display: flex;
flex-direction: column;
align-items: center;
transform: translateY(10px);
transition: transform 0.3s ease;
}
.peer-card.file-drop-target-active .drag-drop-content {
transform: translateY(0);
}
</style>

View File

@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
// --- Vue 核心 --- // --- Vue 核心 ---
import { computed, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
// --- Wails & 后端绑定 --- // --- Wails & 后端绑定 ---
import { Events, Dialogs } from "@wailsio/runtime"; import { Events, Dialogs, Window } from "@wailsio/runtime";
import { SendFiles } from "../../../bindings/mesh-drop/internal/transfer/service"; import { SendFiles } from "../../../bindings/mesh-drop/internal/transfer/service";
import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models"; import { Peer } from "../../../bindings/mesh-drop/internal/discovery/models";
import { File } from "bindings/mesh-drop/models";
onMounted(() => {});
// --- 属性 & 事件 --- // --- 属性 & 事件 ---
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
peer: Peer; peer: Peer;
selectedIp: string; selectedIp: string;
files: File[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -22,7 +26,6 @@ const emit = defineEmits<{
// --- 状态 --- // --- 状态 ---
const { t } = useI18n(); const { t } = useI18n();
const fileList = ref<{ name: string; path: string }[]>([]);
// --- 计算属性 --- // --- 计算属性 ---
const show = computed({ const show = computed({
@@ -34,19 +37,15 @@ const show = computed({
watch(show, (newVal) => { watch(show, (newVal) => {
if (newVal) { if (newVal) {
Events.On("files-dropped", (event) => { Events.On("files-dropped", (event) => {
const files: string[] = event.data.files || []; const files: File[] = event.data.files || [];
files.forEach((f) => { files.forEach((f) => {
if (!fileList.value.find((existing) => existing.path === f)) { if (!props.files.find((existing) => existing.path === f.path)) {
fileList.value.push({ props.files.push(f);
name: f.split(/[\/]/).pop() || f,
path: f,
});
} }
}); });
}); });
} else { } else {
Events.Off("files-dropped"); Events.Off("files-dropped");
fileList.value = [];
} }
}); });
@@ -60,17 +59,17 @@ const openFileDialog = async () => {
if (files) { if (files) {
if (Array.isArray(files)) { if (Array.isArray(files)) {
files.forEach((f) => { files.forEach((f) => {
if (!fileList.value.find((existing) => existing.path === f)) { if (!props.files.find((existing) => existing.path === f)) {
fileList.value.push({ props.files.push({
name: f.split(/[\\/]/).pop() || f, name: f.split(/[\/]/).pop() || f,
path: f, path: f,
}); });
} }
}); });
} else { } else {
const f = files as string; const f = files as string;
if (!fileList.value.find((existing) => existing.path === f)) { if (!props.files.find((existing) => existing.path === f)) {
fileList.value.push({ props.files.push({
name: f.split(/[\\/]/).pop() || f, name: f.split(/[\\/]/).pop() || f,
path: f, path: f,
}); });
@@ -80,12 +79,12 @@ const openFileDialog = async () => {
}; };
const handleRemoveFile = (index: number) => { const handleRemoveFile = (index: number) => {
fileList.value.splice(index, 1); props.files.splice(index, 1);
}; };
const handleSendFiles = async () => { const handleSendFiles = async () => {
if (fileList.value.length === 0 || !props.selectedIp) return; if (props.files.length === 0 || !props.selectedIp) return;
const paths = fileList.value.map((f) => f.path); const paths = props.files.map((f) => f.path);
try { try {
await SendFiles(props.peer, props.selectedIp, paths); await SendFiles(props.peer, props.selectedIp, paths);
@@ -103,10 +102,11 @@ const handleSendFiles = async () => {
<v-card :title="$t('modal.fileSend.title')"> <v-card :title="$t('modal.fileSend.title')">
<v-card-text> <v-card-text>
<div <div
v-if="fileList.length === 0" v-if="props.files.length === 0"
class="drop-zone pa-10 text-center rounded-lg border-dashed" class="drop-zone pa-10 text-center rounded-lg"
@click="openFileDialog" @click="openFileDialog"
data-file-drop-target data-file-drop-target
id="drop-zone-area"
> >
<v-icon <v-icon
icon="mdi-cloud-upload" icon="mdi-cloud-upload"
@@ -127,9 +127,10 @@ const handleSendFiles = async () => {
max-height="400" max-height="400"
style="overflow-y: auto" style="overflow-y: auto"
data-file-drop-target data-file-drop-target
id="drop-zone-list"
> >
<v-list-item <v-list-item
v-for="(file, index) in fileList" v-for="(file, index) in props.files"
:key="file.path" :key="file.path"
:title="file.name" :title="file.name"
:subtitle="file.path" :subtitle="file.path"
@@ -149,8 +150,7 @@ const handleSendFiles = async () => {
<v-btn <v-btn
block block
variant="outlined" variant="tonal"
style="border-style: dashed"
prepend-icon="mdi-plus" prepend-icon="mdi-plus"
@click="openFileDialog" @click="openFileDialog"
class="mt-2" class="mt-2"
@@ -168,10 +168,10 @@ const handleSendFiles = async () => {
<v-btn <v-btn
color="primary" color="primary"
@click="handleSendFiles" @click="handleSendFiles"
:disabled="fileList.length === 0" :disabled="props.files.length === 0"
> >
{{ $t("modal.fileSend.sendSrc") }} {{ $t("modal.fileSend.sendSrc") }}
{{ fileList.length > 0 ? `(${fileList.length})` : "" }} {{ props.files.length > 0 ? `(${props.files.length})` : "" }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
@@ -180,17 +180,32 @@ const handleSendFiles = async () => {
<style scoped> <style scoped>
.drop-zone { .drop-zone {
border: 2px dashed #666; /* Use a darker color or theme var */ border: 2px solid transparent;
border-radius: 12px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
} }
.drop-zone:hover { .drop-zone:hover {
border-color: #38bdf8; background-color: rgba(var(--v-theme-primary), 0.08);
background-color: rgba(56, 189, 248, 0.05);
} }
.drop-zone.file-drop-target-active { .drop-zone.file-drop-target-active {
border-color: #38bdf8; border-color: rgb(var(--v-theme-primary));
background-color: rgba(56, 189, 248, 0.1); background-color: rgba(var(--v-theme-primary), 0.12);
transform: scale(1.01);
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.15);
}
#drop-zone-list {
transition: all 0.3s ease;
}
#drop-zone-list.file-drop-target-active {
box-shadow: inset 0 0 0 2px rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-primary), 0.04);
} }
</style> </style>

View File

@@ -33,7 +33,8 @@
"trustPeer": "Trust Peer", "trustPeer": "Trust Peer",
"untrustPeer": "Untrust Peer", "untrustPeer": "Untrust Peer",
"sendFolderFailed": "Failed to send folder: {error}", "sendFolderFailed": "Failed to send folder: {error}",
"sendClipboardFailed": "Failed to send clipboard: {error}" "sendClipboardFailed": "Failed to send clipboard: {error}",
"dragDropHint": "Drag and drop files here to send"
}, },
"transfers": { "transfers": {
"noTransfers": "No transfers yet", "noTransfers": "No transfers yet",

View File

@@ -33,7 +33,8 @@
"trustPeer": "信任设备", "trustPeer": "信任设备",
"untrustPeer": "取消信任", "untrustPeer": "取消信任",
"sendFolderFailed": "发送文件夹失败: {error}", "sendFolderFailed": "发送文件夹失败: {error}",
"sendClipboardFailed": "发送剪贴板失败: {error}" "sendClipboardFailed": "发送剪贴板失败: {error}",
"dragDropHint": "拖放文件到此处快速发送"
}, },
"transfers": { "transfers": {
"noTransfers": "暂无传输记录", "noTransfers": "暂无传输记录",

View File

@@ -3,12 +3,12 @@ package config
import ( import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"mesh-drop/internal/security"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"github.com/google/uuid" "github.com/google/uuid"
"mesh-drop/internal/security"
) )
// WindowState 定义窗口状态 // WindowState 定义窗口状态
@@ -66,7 +66,7 @@ func GetUserHomeDir() string {
// New 读取配置 // New 读取配置
func Load(defaultState WindowState) *Config { func Load(defaultState WindowState) *Config {
configDir := GetConfigDir() configDir := GetConfigDir()
_ = os.MkdirAll(configDir, 0755) _ = os.MkdirAll(configDir, 0o750)
configFile := filepath.Join(configDir, "config.json") configFile := filepath.Join(configDir, "config.json")
// 设置默认值 // 设置默认值
@@ -88,7 +88,9 @@ func Load(defaultState WindowState) *Config {
TrustedPeer: make(map[string]string), TrustedPeer: make(map[string]string),
} }
fileBytes, err := os.ReadFile(configFile) fileBytes, err := os.ReadFile(
configFile,
)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
slog.Error("Failed to read config file", "error", err) slog.Error("Failed to read config file", "error", err)
@@ -107,7 +109,7 @@ func Load(defaultState WindowState) *Config {
} }
// 确保默认保存路径存在 // 确保默认保存路径存在
err = os.MkdirAll(defaultSavePath, 0755) err = os.MkdirAll(defaultSavePath, 0o750)
if err != nil { if err != nil {
slog.Error("Failed to create default save path", "path", defaultSavePath, "error", err) slog.Error("Failed to create default save path", "path", defaultSavePath, "error", err)
} }
@@ -145,7 +147,7 @@ func (c *Config) Save() error {
func (c *Config) save() error { func (c *Config) save() error {
dir := filepath.Dir(c.configPath) dir := filepath.Dir(c.configPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0o750); err != nil {
return err return err
} }
@@ -156,7 +158,7 @@ func (c *Config) save() error {
// 设置配置文件权限为 0600 (仅所有者读写) // 设置配置文件权限为 0600 (仅所有者读写)
if c.configPath != "" { if c.configPath != "" {
if err := os.WriteFile(c.configPath, jsonData, 0600); err != nil { if err := os.WriteFile(c.configPath, jsonData, 0o600); err != nil {
slog.Warn("Failed to write config file", "error", err) slog.Warn("Failed to write config file", "error", err)
return err return err
} }
@@ -181,7 +183,7 @@ func (c *Config) update(fn func()) {
func (c *Config) SetSavePath(savePath string) { func (c *Config) SetSavePath(savePath string) {
c.update(func() { c.update(func() {
c.data.SavePath = savePath c.data.SavePath = savePath
_ = os.MkdirAll(savePath, 0755) _ = os.MkdirAll(savePath, 0o750)
}) })
} }

View File

@@ -61,3 +61,20 @@ func (p *PresencePacket) SignPayload() []byte {
// 格式: id|name|port|os|pk // 格式: id|name|port|os|pk
return fmt.Appendf(nil, "%s|%s|%d|%s|%s", p.ID, p.Name, p.Port, p.OS, p.PublicKey) return fmt.Appendf(nil, "%s|%s|%d|%s|%s", p.ID, p.Name, p.Port, p.OS, p.PublicKey)
} }
// DeepCopy 返回 Peer 的深拷贝
func (p Peer) DeepCopy() *Peer {
newPeer := p // 结构体浅拷贝 (值类型字段已复制)
// 手动深拷贝引用类型字段 (Routes)
if p.Routes != nil {
newPeer.Routes = make(map[string]*RouteState, len(p.Routes))
for k, v := range p.Routes {
// RouteState 只有值类型字段,但它是指针,所以需要新建对象并解引用赋值
stateCopy := *v
newPeer.Routes[k] = &stateCopy
}
}
return &newPeer
}

View File

@@ -4,8 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"mesh-drop/internal/config"
"mesh-drop/internal/security"
"net" "net"
"runtime" "runtime"
"sort" "sort"
@@ -13,6 +11,8 @@ import (
"time" "time"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
"mesh-drop/internal/config"
"mesh-drop/internal/security"
) )
const ( const (
@@ -112,7 +112,13 @@ func (s *Service) GetLocalIPInSameSubnet(receiverIP string) (string, bool) {
} }
} }
} }
slog.Error("Failed to get local IP in same subnet", "receiverIP", receiverIP, "component", "discovery") slog.Error(
"Failed to get local IP in same subnet",
"receiverIP",
receiverIP,
"component",
"discovery",
)
return "", false return "", false
} }
@@ -222,7 +228,13 @@ func (s *Service) startListening() {
sigData := packet.SignPayload() sigData := packet.SignPayload()
valid, err := security.Verify(packet.PublicKey, sigData, sig) valid, err := security.Verify(packet.PublicKey, sigData, sig)
if err != nil || !valid { if err != nil || !valid {
slog.Warn("Received invalid discovery packet signature", "id", packet.ID, "ip", remoteAddr.IP.String()) slog.Warn(
"Received invalid discovery packet signature",
"id",
packet.ID,
"ip",
remoteAddr.IP.String(),
)
continue continue
} }
@@ -231,7 +243,15 @@ func (s *Service) startListening() {
trustedKeys := s.config.GetTrusted() trustedKeys := s.config.GetTrusted()
if knownKey, ok := trustedKeys[packet.ID]; ok { if knownKey, ok := trustedKeys[packet.ID]; ok {
if knownKey != packet.PublicKey { if knownKey != packet.PublicKey {
slog.Warn("SECURITY ALERT: Peer ID mismatch with known public key (Spoofing attempt?)", "id", packet.ID, "known_key", knownKey, "received_key", packet.PublicKey) slog.Warn(
"SECURITY ALERT: Peer ID mismatch with known public key (Spoofing attempt?)",
"id",
packet.ID,
"known_key",
knownKey,
"received_key",
packet.PublicKey,
)
trustMismatch = true trustMismatch = true
// 当发现 ID 欺骗时,不更新 peer而是标记为 trustMismatch // 当发现 ID 欺骗时,不更新 peer而是标记为 trustMismatch
// 用户可以手动重新添加信任 // 用户可以手动重新添加信任
@@ -337,7 +357,7 @@ func (s *Service) GetPeerByIP(ip string) (*Peer, bool) {
for _, p := range s.peers { for _, p := range s.peers {
if p.Routes[ip] != nil { if p.Routes[ip] != nil {
return p, true return p.DeepCopy(), true
} }
} }
return nil, false return nil, false
@@ -348,7 +368,10 @@ func (s *Service) GetPeerByID(id string) (*Peer, bool) {
defer s.peersMutex.RUnlock() defer s.peersMutex.RUnlock()
peer, ok := s.peers[id] peer, ok := s.peers[id]
return peer, ok if !ok {
return nil, false
}
return peer.DeepCopy(), true
} }
func (s *Service) GetPeers() []Peer { func (s *Service) GetPeers() []Peer {
@@ -357,7 +380,7 @@ func (s *Service) GetPeers() []Peer {
list := make([]Peer, 0) list := make([]Peer, 0)
for _, p := range s.peers { for _, p := range s.peers {
list = append(list, *p) list = append(list, *p.DeepCopy())
} }
sort.Slice(list, func(i, j int) bool { sort.Slice(list, func(i, j int) bool {
return list[i].Name < list[j].Name return list[i].Name < list[j].Name

View File

@@ -52,7 +52,13 @@ func generateSelfSignedCert(certPath, keyPath string) error {
// 在实际的动态环境中,我们可能希望添加所有当前接口的 IP 地址 // 在实际的动态环境中,我们可能希望添加所有当前接口的 IP 地址
// 实际上,在客户端跳过 IP 验证对于本地 P2P 来说是很常见的。 // 实际上,在客户端跳过 IP 验证对于本地 P2P 来说是很常见的。
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) derBytes, err := x509.CreateCertificate(
rand.Reader,
&template,
&template,
&priv.PublicKey,
priv,
)
if err != nil { if err != nil {
return err return err
} }
@@ -73,7 +79,10 @@ func generateSelfSignedCert(certPath, keyPath string) error {
return err return err
} }
defer keyOut.Close() defer keyOut.Close()
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { if err := pem.Encode(
keyOut,
&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)},
); err != nil {
return err return err
} }

View File

@@ -10,13 +10,13 @@ import (
"io" "io"
"log/slog" "log/slog"
"math" "math"
"mesh-drop/internal/discovery"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"github.com/google/uuid" "github.com/google/uuid"
"mesh-drop/internal/discovery"
) )
func (s *Service) SendFiles(target *discovery.Peer, targetIP string, filePaths []string) { func (s *Service) SendFiles(target *discovery.Peer, targetIP string, filePaths []string) {
@@ -32,7 +32,15 @@ func (s *Service) SendFile(target *discovery.Peer, targetIP string, filePath str
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
slog.Error("Failed to open file", "path", filePath, "error", err, "component", "transfer-client") slog.Error(
"Failed to open file",
"path",
filePath,
"error",
err,
"component",
"transfer-client",
)
return return
} }
@@ -101,7 +109,15 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
size, err := calculateTarSize(ctx, folderPath) size, err := calculateTarSize(ctx, folderPath)
if err != nil { if err != nil {
slog.Error("Failed to calculate folder size", "path", folderPath, "error", err, "component", "transfer-client") slog.Error(
"Failed to calculate folder size",
"path",
folderPath,
"error",
err,
"component",
"transfer-client",
)
return return
} }
@@ -137,7 +153,13 @@ func (s *Service) SendFolder(target *discovery.Peer, targetIP string, folderPath
go func(ctx context.Context) { go func(ctx context.Context) {
defer w.Close() defer w.Close()
if err := streamFolderToTar(ctx, w, folderPath); err != nil { if err := streamFolderToTar(ctx, w, folderPath); err != nil {
slog.Error("Failed to stream folder to tar", "error", err, "component", "transfer-client") slog.Error(
"Failed to stream folder to tar",
"error",
err,
"component",
"transfer-client",
)
w.CloseWithError(err) w.CloseWithError(err)
} }
}(ctx) }(ctx)
@@ -199,7 +221,12 @@ func (s *Service) SendText(target *discovery.Peer, targetIP string, text string)
} }
// ask 向接收端发送传输请求 // ask 向接收端发送传输请求
func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP string, task *Transfer) (TransferAskResponse, error) { func (s *Service) ask(
ctx context.Context,
target *discovery.Peer,
targetIP string,
task *Transfer,
) (TransferAskResponse, error) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return TransferAskResponse{}, err return TransferAskResponse{}, err
} }
@@ -232,7 +259,14 @@ func (s *Service) ask(ctx context.Context, target *discovery.Peer, targetIP stri
} }
// processTransfer 传输数据 // processTransfer 传输数据
func (s *Service) processTransfer(ctx context.Context, askResp TransferAskResponse, target *discovery.Peer, targetIP string, task *Transfer, payload io.Reader) { func (s *Service) processTransfer(
ctx context.Context,
askResp TransferAskResponse,
target *discovery.Peer,
targetIP string,
task *Transfer,
payload io.Reader,
) {
defer func() { defer func() {
s.NotifyTransferListUpdate() s.NotifyTransferListUpdate()
}() }()
@@ -240,7 +274,9 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return return
} }
uploadUrl, _ := url.Parse(fmt.Sprintf("https://%s:%d/transfer/upload/%s", targetIP, target.Port, task.ID)) uploadUrl, _ := url.Parse(
fmt.Sprintf("https://%s:%d/transfer/upload/%s", targetIP, target.Port, task.ID),
)
query := uploadUrl.Query() query := uploadUrl.Query()
query.Add("token", askResp.Token) query.Add("token", askResp.Token)
uploadUrl.RawQuery = query.Encode() uploadUrl.RawQuery = query.Encode()
@@ -273,7 +309,15 @@ func (s *Service) processTransfer(ctx context.Context, askResp TransferAskRespon
} else { } else {
task.Status = TransferStatusError task.Status = TransferStatusError
task.ErrorMsg = fmt.Sprintf("Failed to upload file: %v", err) task.ErrorMsg = fmt.Sprintf("Failed to upload file: %v", err)
slog.Error("Failed to upload file", "url", uploadUrl.String(), "error", err, "component", "transfer-client") slog.Error(
"Failed to upload file",
"url",
uploadUrl.String(),
"error",
err,
"component",
"transfer-client",
)
} }
return return
} }
@@ -384,7 +428,15 @@ func streamFolderToTar(ctx context.Context, w io.Writer, srcPath string) error {
if relPath == "." { if relPath == "." {
return nil return nil
} }
slog.Debug("Processing file", "path", path, "relPath", relPath, "component", "transfer-client") slog.Debug(
"Processing file",
"path",
path,
"relPath",
relPath,
"component",
"transfer-client",
)
header, err := tar.FileInfoHeader(info, "") header, err := tar.FileInfoHeader(info, "")
if err != nil { if err != nil {

View File

@@ -3,9 +3,10 @@ package transfer
import ( import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"mesh-drop/internal/config"
"os" "os"
"path/filepath" "path/filepath"
"mesh-drop/internal/config"
) )
func (s *Service) SaveHistory() { func (s *Service) SaveHistory() {
@@ -24,7 +25,7 @@ func (s *Service) SaveHistory() {
} }
// 写入临时文件 // 写入临时文件
if err := os.WriteFile(tempPath, historyJson, 0644); err != nil { if err := os.WriteFile(tempPath, historyJson, 0o600); err != nil {
slog.Error("Failed to write temp history file", "error", err, "component", "transfer") slog.Error("Failed to write temp history file", "error", err, "component", "transfer")
return return
} }

View File

@@ -1,8 +1,9 @@
package transfer package transfer
import ( import (
"mesh-drop/internal/discovery"
"time" "time"
"mesh-drop/internal/discovery"
) )
type TransferStatus string type TransferStatus string

View File

@@ -49,7 +49,8 @@ func (s *Service) handleAsk(c *gin.Context) {
task.Sender.TrustMismatch = peer.TrustMismatch task.Sender.TrustMismatch = peer.TrustMismatch
} }
if s.config.GetAutoAccept() || (s.config.IsTrusted(task.Sender.ID) && !task.Sender.TrustMismatch) { if s.config.GetAutoAccept() ||
(s.config.IsTrusted(task.Sender.ID) && !task.Sender.TrustMismatch) {
task.DecisionChan <- Decision{ task.DecisionChan <- Decision{
ID: task.ID, ID: task.ID,
Accepted: true, Accepted: true,
@@ -179,7 +180,15 @@ func (s *Service) handleUpload(c *gin.Context) {
_, err := os.Stat(destPath) _, err := os.Stat(destPath)
counter := 1 counter := 1
for err == nil { for err == nil {
destPath = filepath.Join(savePath, fmt.Sprintf("%s (%d)%s", strings.TrimSuffix(task.FileName, filepath.Ext(task.FileName)), counter, filepath.Ext(task.FileName))) destPath = filepath.Join(
savePath,
fmt.Sprintf(
"%s (%d)%s",
strings.TrimSuffix(task.FileName, filepath.Ext(task.FileName)),
counter,
filepath.Ext(task.FileName),
),
)
counter++ counter++
_, err = os.Stat(destPath) _, err = os.Stat(destPath)
} }
@@ -227,7 +236,13 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer Writer, ctxRead
if err != nil { if err != nil {
// 发送端断线,任务取消 // 发送端断线,任务取消
if c.Request.Context().Err() != nil { if c.Request.Context().Err() != nil {
slog.Info("Sender canceled transfer (Network/Context disconnected)", "id", task.ID, "raw_err", err) slog.Info(
"Sender canceled transfer (Network/Context disconnected)",
"id",
task.ID,
"raw_err",
err,
)
task.ErrorMsg = "Sender disconnected" task.ErrorMsg = "Sender disconnected"
task.Status = TransferStatusCanceled task.Status = TransferStatusCanceled
return return
@@ -273,7 +288,12 @@ func (s *Service) receive(c *gin.Context, task *Transfer, writer Writer, ctxRead
task.Status = TransferStatusCompleted task.Status = TransferStatusCompleted
} }
func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer, ctxReader io.Reader) { func (s *Service) receiveFolder(
c *gin.Context,
savePath string,
task *Transfer,
ctxReader io.Reader,
) {
defer s.NotifyTransferListUpdate() defer s.NotifyTransferListUpdate()
// 创建根目录 // 创建根目录
@@ -286,7 +306,7 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
counter++ counter++
_, err = os.Stat(destPath) _, err = os.Stat(destPath)
} }
if err := os.MkdirAll(destPath, 0755); err != nil { if err := os.MkdirAll(destPath, 0o750); err != nil {
c.JSON(http.StatusInternalServerError, TransferUploadResponse{ c.JSON(http.StatusInternalServerError, TransferUploadResponse{
ID: task.ID, ID: task.ID,
Message: "Receiver failed to create folder", Message: "Receiver failed to create folder",
@@ -318,7 +338,13 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
return false return false
} }
if c.Request.Context().Err() != nil { if c.Request.Context().Err() != nil {
slog.Info("Transfer canceled by sender (Network disconnect)", "id", task.ID, "stage", stage) slog.Info(
"Transfer canceled by sender (Network disconnect)",
"id",
task.ID,
"stage",
stage,
)
task.Status = TransferStatusCanceled task.Status = TransferStatusCanceled
task.ErrorMsg = "Sender disconnected" task.ErrorMsg = "Sender disconnected"
// 发送端已断开,无需也不应再发送 c.JSON // 发送端已断开,无需也不应再发送 c.JSON
@@ -350,6 +376,14 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
return true return true
} }
// 获取绝对路径以防止 Zip Slip (G305)
// 必须先转换成绝对路径再判断
absDestPath, err := filepath.Abs(destPath)
if err != nil {
handleError(err, "resolve_abs_path")
return
}
tr := tar.NewReader(reader) tr := tar.NewReader(reader)
for { for {
header, err := tr.Next() header, err := tr.Next()
@@ -360,32 +394,52 @@ func (s *Service) receiveFolder(c *gin.Context, savePath string, task *Transfer,
return return
} }
target := filepath.Join(destPath, header.Name) target := filepath.Join(destPath, filepath.Clean(header.Name))
// 确保路径没有越界 absTarget, err := filepath.Abs(target)
if !strings.HasPrefix(target, filepath.Clean(destPath)+string(os.PathSeparator)) { if err != nil {
// 非法路径 slog.Error("Failed to resolve absolute path", "path", target, "error", err)
continue continue
} }
// 确保路径在目标目录内
if !strings.HasPrefix(absTarget, absDestPath+string(os.PathSeparator)) {
slog.Warn(
"Zip Slip attempt detected",
"header_name",
header.Name,
"resolved_path",
absTarget,
)
continue
}
// 使用安全的绝对路径
target = absTarget
switch header.Typeflag { switch header.Typeflag {
case tar.TypeDir: case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil { if err := os.MkdirAll(target, 0o750); err != nil {
slog.Error("Failed to create dir", "path", target, "error", err) slog.Error("Failed to create dir", "path", target, "error", err)
} }
case tar.TypeReg: case tar.TypeReg:
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) f, err := os.OpenFile(
target,
os.O_CREATE|os.O_RDWR,
os.FileMode(header.Mode),
) //nolint:gosec
if err != nil { if err != nil {
slog.Error("Failed to create file", "path", target, "error", err) slog.Error("Failed to create file", "path", target, "error", err)
continue continue
} }
// nolint: gosec
if _, err := io.Copy(f, tr); err != nil { if _, err := io.Copy(f, tr); err != nil {
f.Close() _ = f.Close()
if handleError(err, "write_file_content") { if handleError(err, "write_file_content") {
return return
} }
} }
f.Close() _ = f.Close()
} }
} }

View File

@@ -5,9 +5,6 @@ import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"log/slog" "log/slog"
"mesh-drop/internal/config"
"mesh-drop/internal/discovery"
"mesh-drop/internal/security"
"net/http" "net/http"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -16,6 +13,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications" "github.com/wailsapp/wails/v3/pkg/services/notifications"
"mesh-drop/internal/config"
"mesh-drop/internal/discovery"
"mesh-drop/internal/security"
) )
type Service struct { type Service struct {
@@ -37,12 +37,18 @@ type Service struct {
httpClient *http.Client httpClient *http.Client
} }
func NewService(config *config.Config, app *application.App, notifier *notifications.NotificationService, port int, discoveryService *discovery.Service) *Service { func NewService(
config *config.Config,
app *application.App,
notifier *notifications.NotificationService,
port int,
discoveryService *discovery.Service,
) *Service {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
// 配置自定义 HTTP 客户端以跳过自签名证书验证 // 配置自定义 HTTP 客户端以跳过自签名证书验证
tr := &http.Transport{ tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
} }
httpClient := &http.Client{ httpClient := &http.Client{
Transport: tr, Transport: tr,
@@ -95,7 +101,7 @@ func (s *Service) GetTransferSyncMap() *sync.Map {
} }
func (s *Service) GetTransferList() []*Transfer { func (s *Service) GetTransferList() []*Transfer {
var requests []*Transfer = make([]*Transfer, 0) requests := make([]*Transfer, 0)
s.transfers.Range(func(key, value any) bool { s.transfers.Range(func(key, value any) bool {
transfer := value.(*Transfer) transfer := value.(*Transfer)
requests = append(requests, transfer) requests = append(requests, transfer)

41
main.go
View File

@@ -3,14 +3,15 @@ package main
import ( import (
"embed" "embed"
"log/slog" "log/slog"
"mesh-drop/internal/config"
"mesh-drop/internal/discovery"
"mesh-drop/internal/transfer"
"os" "os"
"path/filepath"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications" "github.com/wailsapp/wails/v3/pkg/services/notifications"
"mesh-drop/internal/config"
"mesh-drop/internal/discovery"
"mesh-drop/internal/transfer"
) )
//go:embed all:frontend/dist //go:embed all:frontend/dist
@@ -19,8 +20,13 @@ var assets embed.FS
//go:embed build/appicon.png //go:embed build/appicon.png
var icon []byte var icon []byte
type File struct {
Name string `json:"name"`
Path string `json:"path"`
}
type FilesDroppedEvent struct { type FilesDroppedEvent struct {
Files []string `json:"files"` Files []File `json:"files"`
Target string `json:"target"` Target string `json:"target"`
} }
@@ -61,7 +67,17 @@ func NewApp() *App {
if screen != nil { if screen != nil {
defaultWidth = int(float64(screen.Size.Width) * 0.8) defaultWidth = int(float64(screen.Size.Width) * 0.8)
defaultHeight = int(float64(screen.Size.Height) * 0.8) defaultHeight = int(float64(screen.Size.Height) * 0.8)
slog.Info("Primary screen found", "width", screen.Size.Width, "height", screen.Size.Height, "defaultWidth", defaultWidth, "defaultHeight", defaultHeight) slog.Info(
"Primary screen found",
"width",
screen.Size.Width,
"height",
screen.Size.Height,
"defaultWidth",
defaultWidth,
"defaultHeight",
defaultHeight,
)
} else { } else {
slog.Info("No primary screen found, using defaults") slog.Info("No primary screen found, using defaults")
} }
@@ -131,14 +147,23 @@ func (a *App) registerCustomEvents() {
func (a *App) setupEvents() { func (a *App) setupEvents() {
// 窗口文件拖拽事件 // 窗口文件拖拽事件
a.mainWindows.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) { a.mainWindows.OnWindowEvent(
files := event.Context().DroppedFiles() events.Common.WindowFilesDropped,
func(event *application.WindowEvent) {
files := make([]File, 0)
for _, file := range event.Context().DroppedFiles() {
files = append(files, File{
Name: filepath.Base(file),
Path: file,
})
}
details := event.Context().DropTargetDetails() details := event.Context().DropTargetDetails()
a.app.Event.Emit("files-dropped", FilesDroppedEvent{ a.app.Event.Emit("files-dropped", FilesDroppedEvent{
Files: files, Files: files,
Target: details.ElementID, Target: details.ElementID,
}) })
}) },
)
// 窗口关闭事件 // 窗口关闭事件
a.mainWindows.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) { a.mainWindows.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {