MSBD5017-Depin-WebClient/app/Components/ServerList.tsx
2025-12-06 16:54:21 +08:00

319 lines
14 KiB
TypeScript

import * as React from 'react';
import ButtonGroup from '@mui/material/ButtonGroup';
import Button from '@mui/material/Button';
import Avatar from '@mui/material/Avatar';
import {
Grid,
Card,
CardContent,
CardActions,
Typography,
Box,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
Tooltip,
CircularProgress,
} from "@mui/material";
import ServerIcon from '@mui/icons-material/Dns';
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
import StarIcon from '@mui/icons-material/Star';
import Rating from '@mui/material/Rating';
import RefreshIcon from '@mui/icons-material/Refresh';
import useAuth from '~/hooks/useAuth';
import type { AccountInfo } from '~/context/AuthProvider';
import { signMessage } from './Metamask/Connections';
import { generateWireguardKeyPair, downloadWireguardConfig } from './WireguardConfig';
import type { Node } from './Util';
import { getActiveNodes, getNextNonce, processPayment, abortConnection } from './Contracts/Connections';
import axios from 'axios';
import { useEffect } from 'react';
import { Web3 } from 'web3';
function NodeItem({node, auth}: {node: Node, auth: AccountInfo}) {
const [ratingOpen, setRatingOpen] = React.useState(false);
const [ratingValue, setRatingValue] = React.useState<number | null>(null);
const [submittingRating, setSubmittingRating] = React.useState(false);
let nodeSignature = "";
const getUrl = () => `http://${node.ip}:${node.port}`;
const connect = async () => {
try {
const { privatekey: clientPrivateKey, publicKey: clientPublicKey } = generateWireguardKeyPair();
const nonce: BigInt = await getNextNonce(auth.providerWithInfo.provider, auth.accounts[0]);
const connectionStartTime = Math.floor(Date.now() / 1000);
console.log("Nonce:", nonce.toString());
const sig = await signMessage(nonce + clientPublicKey + connectionStartTime + node.pricePerMinute,auth.providerWithInfo.provider, auth.accounts[0]);
const res_string = nonce + '\n' + clientPublicKey + '\n' + connectionStartTime + '\n' + node.pricePerMinute + '\n' + sig;
let response = await axios.post(getUrl() + "/connect", res_string);
const clientCIDR = response.data.WireguardClientCIDR;
const serverPublicKey = response.data.WireguardServerPublicKey;
const peerPort = response.data.WireguardPort;
const dns = response.data.WireguardDNS;
console.log(response.data);
if(response.data.NodeSignature){
const provider = auth.providerWithInfo.provider;
const web3 = new Web3(provider);
const recoveredAddress = web3.eth.accounts.recover(nonce.toString() + connectionStartTime + node.pricePerMinute, response.data.NodeSignature);
if(recoveredAddress === node.address){
nodeSignature = response.data.NodeSignature;
}
}
if(nodeSignature === ""){
await abortConnection(auth.providerWithInfo.provider, auth.accounts[0],
clientPublicKey,
BigInt(connectionStartTime),
node.pricePerMinute,
String(sig)
);
return;
}
localStorage.setItem(node.address, JSON.stringify({
vpnClientPublicKey: clientPublicKey,
connectionStartTime: connectionStartTime,
agreedPricePerMinute: node.pricePerMinute.toString(),
clientSignature: String(sig),
nodeSignature: nodeSignature
}));
downloadWireguardConfig(clientPrivateKey, serverPublicKey, clientCIDR, dns, node.ip, String(peerPort), "0.0.0.0/0");
} catch (error) {
console.error('Error:', error);
}
}
const sendPayment = async (isRatingProvided: boolean, rating: BigInt) => {
try{
const storedInfo = localStorage.getItem(node.address);
if(!storedInfo){
console.error("No active connection info");
return;
}
const connectionInfo = JSON.parse(storedInfo);
if (isRatingProvided) {
const nonce: BigInt = await getNextNonce(auth.providerWithInfo.provider, auth.accounts[0]);
connectionInfo.clientSignature = await signMessage(nonce + connectionInfo.vpnClientPublicKey + connectionInfo.connectionStartTime + connectionInfo.agreedPricePerMinute + rating,auth.providerWithInfo.provider, auth.accounts[0]);
localStorage.setItem(node.address,JSON.stringify(connectionInfo))
}
await processPayment(auth.providerWithInfo.provider, auth.accounts[0],
node.address,
connectionInfo.vpnClientPublicKey,
BigInt(connectionInfo.connectionStartTime),
BigInt(connectionInfo.agreedPricePerMinute),
isRatingProvided,
rating,
connectionInfo.clientSignature,
connectionInfo.nodeSignature
);
localStorage.removeItem(node.address);
} catch (error) {
console.error('Error:', error);
}
}
const disconnect = async () => {
// try{
// const nonce: BigInt = await getNextNonce(auth.providerWithInfo.provider, auth.accounts[0]);
// const sig = await signMessage(String(nonce),auth.providerWithInfo.provider, auth.accounts[0]);
// const res_string = String(nonce) + '\n' + sig;
// let response = await axios.post(getUrl() + "/disconnect", res_string);
// console.log(response.data);
// } catch (error) {
// console.error('Error:', error);
// }
setRatingOpen(true);
}
const handleNoRating = () => {
console.log("no rating");
sendPayment(false, BigInt(0));
handleCloseRating()
}
const handleCloseRating = () => {
setRatingOpen(false);
setRatingValue(null);
};
const handleSubmitRating = () => {
if (ratingValue == null) {
console.log("no rating value");
sendPayment(false, BigInt(0));
return;
}
console.log("submit rating:", ratingValue);
setSubmittingRating(true);
sendPayment(true, BigInt(ratingValue));
setSubmittingRating(false);
handleCloseRating();
}
return (
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Card elevation={3} sx={{ height: '100%', display: 'flex', flexDirection: 'column', borderRadius: 2 }}>
<CardContent sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
<ServerIcon />
</Avatar>
<Typography variant="h6" component="div" noWrap>
{node.ip}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SignalCellularAltIcon color="action" fontSize="small" />
<Typography variant="body2" color="text.secondary">
Traffic Load:
</Typography>
<Chip
label={node.traffic}
size="small"
color={node.traffic > 5 ? "warning" : "success"}
variant="outlined"
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AttachMoneyIcon color="action" fontSize="small" />
<Typography variant="body2" color="text.secondary">
Price:
</Typography>
<Chip
label={node.price}
size="small"
color={node.price > 15 ? "warning" : "success"}
variant="outlined"
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<StarIcon color="action" fontSize="small" />
<Typography variant="body2" color="text.secondary">
Rating:
</Typography>
<Chip
label={node.rating}
size="small"
color={node.rating > 3 ? "success" : "warning"}
variant="outlined"
/>
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', p: 2, pt: 0 }}>
<ButtonGroup
disableElevation
variant="contained"
size="small"
>
<Button
onClick={() => disconnect()}
color="error"
variant="outlined"
>
Disconnect
</Button>
<Button
onClick={() => connect()}
color="primary"
>
Connect
</Button>
</ButtonGroup>
</CardActions>
</Card>
<Dialog open={ratingOpen} onClose={handleNoRating}>
<DialogTitle>Rate this server</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 320 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography component="span">Your rating:</Typography>
<Rating
name={`server-rating-${node.ip}`}
value={ratingValue}
onChange={(_, newValue) => setRatingValue(newValue)}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleNoRating} disabled={submittingRating}>Cancel</Button>
<Button onClick={handleSubmitRating} disabled={submittingRating || ratingValue == null} variant="contained">Submit</Button>
</DialogActions>
</Dialog>
</Grid>
);
}
export default function FolderList() {
const { auth } = useAuth();
const [nodes, setNodes] = React.useState<Node[]>([]);
const [loading, setLoading] = React.useState(false);
const fetchNodes = React.useCallback(async () => {
setLoading(true);
try {
const provider = auth?.providerWithInfo?.provider;
const fetchedNodes = await getActiveNodes(provider);
setNodes(fetchedNodes);
} catch (err) {
console.error("Failed to fetch nodes", err);
} finally {
setLoading(false);
}
}, [auth]);
useEffect(() => {
fetchNodes();
}, [fetchNodes]);
// const [nodes, setNodes] = React.useState<Node[]>([
// { ip: "57.158.82.48", traffic: 5, price: 10, rating: 3 },
// { ip: "8.210.33.199", traffic: 3, price: 15, rating: 4 },
// { ip: "45.77.12.5", traffic: 7, price: 20, rating: 5 },
// { ip: "203.120.45.78", traffic: 2, price: 8, rating: 2 },
// { ip: "91.189.88.25", traffic: 6, price: 12, rating: 4 },
// { ip: "132.148.9.201", traffic: 9, price: 18, rating: 5 },
// { ip: "60.12.180.99", traffic: 4, price: 14, rating: 3 },
// { ip: "199.59.243.100", traffic: 1, price: 6, rating: 1 },
// { ip: "34.216.77.3", traffic: 8, price: 22, rating: 5 },
// { ip: "185.199.108.153", traffic: 5, price: 11, rating: 4 },
// { ip: "13.107.21.200", traffic: 7, price: 16, rating: 4 },
// { ip: "216.58.214.14", traffic: 3, price: 9, rating: 2 },
// { ip: "104.21.44.33", traffic: 10, price: 25, rating: 5 },
// { ip: "47.90.12.201", traffic: 2, price: 7, rating: 1 },
// { ip: "23.45.67.89", traffic: 6, price: 13, rating: 3 },
// { ip: "192.0.2.123", traffic: 4, price: 17, rating: 4 },
// ]);
return (
<Box sx={{ flexGrow: 1, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4" component="div" sx={{ fontWeight: 'bold' }}>
Available Servers
</Typography>
<Box>
<Tooltip title="Refresh servers">
<span>
<IconButton
onClick={fetchNodes}
disabled={loading || auth?.providerWithInfo === undefined}
color="primary"
aria-label="refresh servers"
>
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
</IconButton>
</span>
</Tooltip>
</Box>
</Box>
<Grid container spacing={3}>
{nodes.map((node, index) => (
<NodeItem key={index} node={node} auth={auth}/>
))}
</Grid>
</Box>
);
}