diff --git a/go-backend/internal/http/handler/federation.go b/go-backend/internal/http/handler/federation.go index ab3b459..8c7ed0d 100644 --- a/go-backend/internal/http/handler/federation.go +++ b/go-backend/internal/http/handler/federation.go @@ -132,6 +132,10 @@ func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request) response.WriteJSON(w, response.ErrDefault("Node not found")) return } + if node.IsRemote == 1 { + response.WriteJSON(w, response.ErrDefault("Only local nodes can be shared")) + return + } now := time.Now().UnixMilli() token := randomToken(32) diff --git a/go-backend/internal/http/handler/federation_share_test.go b/go-backend/internal/http/handler/federation_share_test.go new file mode 100644 index 0000000..f0b4746 --- /dev/null +++ b/go-backend/internal/http/handler/federation_share_test.go @@ -0,0 +1,78 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "go-backend/internal/http/response" + "go-backend/internal/store/sqlite" +) + +func TestFederationShareCreateRejectsRemoteNode(t *testing.T) { + repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = repo.Close() }) + + h := New(repo, "test-jwt-secret") + now := time.Now().UnixMilli() + + insertRes, err := repo.DB().Exec(` + INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "remote-share-node", "remote-share-secret", "10.10.10.1", "10.10.10.1", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":1}`) + if err != nil { + t.Fatalf("insert remote node: %v", err) + } + remoteNodeID, err := insertRes.LastInsertId() + if err != nil { + t.Fatalf("get remote node id: %v", err) + } + + body, err := json.Marshal(createPeerShareRequest{ + Name: "remote-node-share", + NodeID: remoteNodeID, + MaxBandwidth: 0, + ExpiryTime: 0, + PortRangeStart: 20000, + PortRangeEnd: 20010, + }) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/create", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + h.federationShareCreate(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code) + } + + var payload response.R + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Code != -1 { + t.Fatalf("expected response code -1, got %d", payload.Code) + } + if payload.Msg != "Only local nodes can be shared" { + t.Fatalf("expected rejection message %q, got %q", "Only local nodes can be shared", payload.Msg) + } + + var shareCount int + if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM peer_share WHERE node_id = ?`, remoteNodeID).Scan(&shareCount); err != nil { + t.Fatalf("query peer_share count: %v", err) + } + if shareCount != 0 { + t.Fatalf("expected no share rows for remote node, got %d", shareCount) + } +} diff --git a/vite-frontend/src/pages/panel-sharing.tsx b/vite-frontend/src/pages/panel-sharing.tsx index c78a9ec..e179648 100644 --- a/vite-frontend/src/pages/panel-sharing.tsx +++ b/vite-frontend/src/pages/panel-sharing.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Button } from "@heroui/button"; import { Card, CardBody, CardHeader } from "@heroui/card"; import { Tabs, Tab } from "@heroui/tabs"; @@ -23,6 +23,7 @@ import { interface Node { id: number; name: string; + isRemote?: number; } interface PeerShare { @@ -63,14 +64,7 @@ export default function PanelSharingPage() { token: "", }); - useEffect(() => { - if (selectedTab === "my-shares") { - loadShares(); - loadNodes(); - } - }, [selectedTab]); - - const loadShares = async () => { + const loadShares = useCallback(async () => { setLoading(true); try { const res = await getPeerShareList(); @@ -82,30 +76,54 @@ export default function PanelSharingPage() { } finally { setLoading(false); } - }; + }, []); - const loadNodes = async () => { + const loadNodes = useCallback(async () => { try { const res = await getNodeList(); if (res.code === 0) { - setNodes(res.data || []); + const localNodes: Node[] = (res.data || []).filter( + (node: Node) => (node?.isRemote ?? 0) !== 1, + ); + setNodes(localNodes); + setShareForm((prev) => { + if (!prev.nodeId) { + return prev; + } + const hasSelectedNode = localNodes.some( + (node: Node) => String(node.id) === prev.nodeId, + ); + return hasSelectedNode ? prev : { ...prev, nodeId: "" }; + }); } } catch { // ignore } - }; + }, []); + + useEffect(() => { + if (selectedTab === "my-shares") { + loadShares(); + loadNodes(); + } + }, [selectedTab, loadShares, loadNodes]); const handleCreateShare = async () => { if (!shareForm.name || !shareForm.nodeId) { toast.error("请填写必要信息"); return; } + const nodeId = parseInt(shareForm.nodeId, 10); + if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) { + toast.error("仅可选择本地节点"); + return; + } try { const expiryTime = Date.now() + shareForm.expiryDays * 24 * 60 * 60 * 1000; const res = await createPeerShare({ name: shareForm.name, - nodeId: parseInt(shareForm.nodeId), + nodeId, maxBandwidth: shareForm.maxBandwidth * 1024 * 1024 * 1024, expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime, portRangeStart: shareForm.portRangeStart, @@ -249,7 +267,7 @@ export default function PanelSharingPage() { />