Scam warning: there is no TIWD token, logo added
This repository intentionally contains a single commit to protect the anonymity of the founding team.
This commit is contained in:
commit
fdcb014328
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
# Secrets — never commit
|
||||
.env
|
||||
|
||||
# Build artifacts
|
||||
target/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Node identity keys
|
||||
*.key
|
||||
identity.key
|
||||
4091
Cargo.lock
generated
Normal file
4091
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
Cargo.toml
Normal file
44
Cargo.toml
Normal file
@ -0,0 +1,44 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/tiwd-core",
|
||||
"crates/tiwd-trust",
|
||||
"crates/tiwd-chat",
|
||||
"crates/tiwd-sdk",
|
||||
"crates/tiwd-node",
|
||||
]
|
||||
exclude = ["app/src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
libp2p = { version = "0.54", features = [
|
||||
"tokio",
|
||||
"tcp",
|
||||
"noise",
|
||||
"yamux",
|
||||
"kad",
|
||||
"mdns",
|
||||
"gossipsub",
|
||||
"identify",
|
||||
"macros",
|
||||
"ed25519",
|
||||
"request-response",
|
||||
"cbor",
|
||||
"dns",
|
||||
] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_cbor = "0.11"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
directories = "5"
|
||||
futures = "0.3"
|
||||
async-trait = "0.1"
|
||||
warp = { version = "0.3", features = ["tls"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
15
FOUNDING_TEAM.gpg
Normal file
15
FOUNDING_TEAM.gpg
Normal file
@ -0,0 +1,15 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEadLbFRYJKwYBBAHaRw8BAQdA9Fc+XI7m7LWd7wTI+C+mt0rGQtq3UIExRGEx
|
||||
nd747Um0GXRpd2QgPHRpd2QyMDI2QHByb3Rvbi5tZT6IkAQTFggAOBYhBN5VZpAh
|
||||
r/dzbrMLAEP8qxA3drGuBQJp0tsVAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
|
||||
AAoJEEP8qxA3drGulFIBANDtCOADmTn8aaMqvsjReblkoScoxCHLVHmQKa4oaYvP
|
||||
APoCxNi2G6FrRv6Blg2YkyHsZbRFGOT2R+Auft4L/4eECrgzBGnS2xUWCSsGAQQB
|
||||
2kcPAQEHQLsirmPG36TN9RaOVfXWZkOKKp8Rpx23qnxEftrt9TFmiO8EGBYIACAW
|
||||
IQTeVWaQIa/3c26zCwBD/KsQN3axrgUCadLbFQIbAgCBCRBD/KsQN3axrnYgBBkW
|
||||
CAAdFiEEaim3R8XGmjfqD01IcdBy2+B2X0QFAmnS2xUACgkQcdBy2+B2X0Q9fQD/
|
||||
ZqOTPDQF4/7LDx64YyRfOk+tDiV7zv0Z2gjEQZxntyMBAPARrpbYrZcMAuL7cPIT
|
||||
JRNwEZQCLo0YC7VNyi/TcIsEkF0BAJTSMppNLm7DWzUl7Fp2xUkSlVXuUCX6+kUa
|
||||
8EnhNYUdAQDORgJhC6ysA2Rg2gVvjGhpLd473Z5yepwrXz37MWWYDA==
|
||||
=DFOl
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
26
PROOF.asc
Normal file
26
PROOF.asc
Normal file
@ -0,0 +1,26 @@
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
We are the founding team of TIWD — The Internet We Deserve.
|
||||
|
||||
This GPG key (fingerprint DE55 6690 21AF F773 6EB3 0B00 43FC AB10 3776 B1AE)
|
||||
is the sole proof of our identity. Whoever controls the private key
|
||||
corresponding to this fingerprint is the original founding team of TIWD.
|
||||
|
||||
This key was generated on 2026-04-05, the same day the project was created.
|
||||
|
||||
Any communication signed by this key is authentic.
|
||||
Any communication NOT signed by this key should not be assumed to be from us.
|
||||
|
||||
If we ever need to transfer leadership, we will sign a message with this key
|
||||
designating our successors. Without such a signed message, no one can claim
|
||||
to speak on our behalf.
|
||||
|
||||
This is our only identity. We have no other.
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iIkEARYIADEWIQRqKbdHxcaaN+oPTUhx0HLb4HZfRAUCadLjoBMcdGl3ZDIwMjZA
|
||||
cHJvdG9uLm1lAAoJEHHQctvgdl9EqaMBAIM+bJ7Zpt7kOxRW14NP1W0ST2Ez+aOt
|
||||
WqQQGt5EdWuCAQD5hZ0t6Z6A/u3LyAcF+fPkrYuGAOjNVh6cGmhtxqXsBg==
|
||||
=tGY/
|
||||
-----END PGP SIGNATURE-----
|
||||
103
app/index.html
Normal file
103
app/index.html
Normal file
@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TIWD</title>
|
||||
<link rel="stylesheet" href="/src/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Top Bar: Address bar + controls -->
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="nav-btn" id="btnBack" title="Back">←</button>
|
||||
<button class="nav-btn" id="btnForward" title="Forward">→</button>
|
||||
<button class="nav-btn" id="btnRefresh" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="address-bar">
|
||||
<div class="address-icon">🌐</div>
|
||||
<input type="text" id="addressInput" placeholder="Enter a .tiwd address or URL..." autocomplete="off" spellcheck="false">
|
||||
<button class="go-btn" id="btnGo">Go</button>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="nav-btn" id="btnChat" title="Chat">💬</button>
|
||||
<button class="nav-btn" id="btnIdentity" title="Identity">👤</button>
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="peerCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="tabbar" id="tabBar">
|
||||
<div class="tab active" data-tab="home">
|
||||
<span class="tab-title">Home</span>
|
||||
<button class="tab-close">×</button>
|
||||
</div>
|
||||
<button class="tab-new" id="btnNewTab">+</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="content-area">
|
||||
<!-- Browser View -->
|
||||
<div class="browser-view" id="browserView">
|
||||
<iframe id="contentFrame" sandbox="allow-scripts allow-same-origin"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Chat Sidebar (hidden by default) -->
|
||||
<div class="chat-sidebar" id="chatSidebar">
|
||||
<div class="chat-header-bar">
|
||||
<span>Chat</span>
|
||||
<button class="nav-btn" id="btnCloseChat">×</button>
|
||||
</div>
|
||||
<div class="chat-messages" id="chatMessages"></div>
|
||||
<div class="chat-input-area">
|
||||
<input type="text" id="chatInput" placeholder="Message..." autocomplete="off">
|
||||
<button class="go-btn" id="btnSendChat">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="statusbar">
|
||||
<span id="statusText">Ready</span>
|
||||
<span class="statusbar-right">
|
||||
<span id="trustBadge">Newcomer</span>
|
||||
<span id="nodeName">-</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Identity Panel (hidden) -->
|
||||
<div class="identity-panel" id="identityPanel">
|
||||
<h3>Your Identity</h3>
|
||||
<div class="id-field">
|
||||
<label>TIWD Name</label>
|
||||
<div id="idName">Not registered</div>
|
||||
</div>
|
||||
<div class="id-field">
|
||||
<label>Peer ID</label>
|
||||
<div id="idPeer" class="mono">-</div>
|
||||
</div>
|
||||
<div class="id-field">
|
||||
<label>Trust Score</label>
|
||||
<div id="idTrust">0.0 / 100</div>
|
||||
</div>
|
||||
<div class="id-field">
|
||||
<label>Role</label>
|
||||
<div id="idRole">Newcomer</div>
|
||||
</div>
|
||||
<div class="id-field">
|
||||
<label>Peers</label>
|
||||
<div id="idPeers">0 connected</div>
|
||||
</div>
|
||||
<div class="id-actions">
|
||||
<input type="text" id="registerInput" placeholder="Register a name...">
|
||||
<button class="go-btn" id="btnRegister">Register</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1549
app/package-lock.json
generated
Normal file
1549
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
app/package.json
Normal file
15
app/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "tiwd",
|
||||
"version": "0.1.0",
|
||||
"description": "TIWD — The Internet We Deserve",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
4981
app/src-tauri/Cargo.lock
generated
Normal file
4981
app/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
app/src-tauri/Cargo.toml
Normal file
14
app/src-tauri/Cargo.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "tiwd-app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "TIWD Desktop App — The Internet We Deserve"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
3
app/src-tauri/build.rs
Normal file
3
app/src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
1
app/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
app/src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src-tauri/gen/schemas/capabilities.json
Normal file
1
app/src-tauri/gen/schemas/capabilities.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
2244
app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2244
app/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2244
app/src-tauri/gen/schemas/windows-schema.json
Normal file
2244
app/src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
app/src-tauri/icons/icon.ico
Normal file
BIN
app/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
app/src-tauri/icons/icon.png
Normal file
BIN
app/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 938 B |
BIN
app/src-tauri/icons/logo.png
Normal file
BIN
app/src-tauri/icons/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
24
app/src-tauri/src/main.rs
Normal file
24
app/src-tauri/src/main.rs
Normal file
@ -0,0 +1,24 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|_app| {
|
||||
// TODO: Start the TIWD node in the background
|
||||
// This will start:
|
||||
// - P2P networking (port 9000)
|
||||
// - Web server for chat UI (port 8080)
|
||||
// - .tiwd proxy (port 9080)
|
||||
//
|
||||
// For now, the user runs the node separately via run-node.bat
|
||||
// and the Tauri app connects to it via WebSocket.
|
||||
//
|
||||
// Future: embed the node directly into the Tauri binary
|
||||
// so everything runs from one executable.
|
||||
|
||||
println!("TIWD Desktop App starting...");
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running TIWD");
|
||||
}
|
||||
36
app/src-tauri/tauri.conf.json
Normal file
36
app/src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicsgithub/nicsgithub.github.io/refs/heads/main/tauri-v2.schema.json",
|
||||
"productName": "TIWD",
|
||||
"version": "0.1.0",
|
||||
"identifier": "org.tiwd.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "TIWD — The Internet We Deserve",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"decorations": true,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
265
app/src/main.js
Normal file
265
app/src/main.js
Normal file
@ -0,0 +1,265 @@
|
||||
// ── TIWD Browser App ──
|
||||
|
||||
let ws = null;
|
||||
let myPeerId = '';
|
||||
let myName = '';
|
||||
let peers = [];
|
||||
let chatOpen = false;
|
||||
let identityOpen = false;
|
||||
|
||||
// ── WebSocket ──
|
||||
function connect() {
|
||||
ws = new WebSocket('ws://localhost:8080/ws');
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById('statusDot').style.background = '#00d4aa';
|
||||
setStatus('Connected to TIWD network');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
document.getElementById('statusDot').style.background = '#f38ba8';
|
||||
setStatus('Disconnected — reconnecting...');
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
handleMessage(JSON.parse(e.data));
|
||||
} catch (err) {}
|
||||
};
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'identity':
|
||||
myPeerId = msg.peer_id;
|
||||
myName = msg.name || '';
|
||||
document.getElementById('idPeer').textContent = myPeerId;
|
||||
document.getElementById('idName').textContent = myName ? myName + '.tiwd' : 'Not registered';
|
||||
document.getElementById('nodeName').textContent = myName ? myName + '.tiwd' : myPeerId.substring(0, 12);
|
||||
break;
|
||||
|
||||
case 'peer_discovered':
|
||||
if (!peers.includes(msg.peer_id)) peers.push(msg.peer_id);
|
||||
document.getElementById('peerCount').textContent = peers.length;
|
||||
document.getElementById('idPeers').textContent = peers.length + ' connected';
|
||||
addChatMsg('system', null, (msg.display || msg.peer_id.substring(0, 12)) + ' joined');
|
||||
break;
|
||||
|
||||
case 'peer_disconnected':
|
||||
peers = peers.filter(p => p !== msg.peer_id);
|
||||
document.getElementById('peerCount').textContent = peers.length;
|
||||
document.getElementById('idPeers').textContent = peers.length + ' connected';
|
||||
break;
|
||||
|
||||
case 'dm_received':
|
||||
addChatMsg('other', msg.from_name || msg.from.substring(0, 12), msg.text);
|
||||
// Open chat if closed
|
||||
if (!chatOpen) toggleChat();
|
||||
break;
|
||||
|
||||
case 'dm_sent':
|
||||
addChatMsg('self', null, msg.text);
|
||||
break;
|
||||
|
||||
case 'name_registered':
|
||||
myName = msg.name;
|
||||
document.getElementById('idName').textContent = msg.name + '.tiwd';
|
||||
document.getElementById('nodeName').textContent = msg.name + '.tiwd';
|
||||
addChatMsg('system', null, 'Registered: ' + msg.name + '.tiwd');
|
||||
break;
|
||||
|
||||
case 'name_resolved':
|
||||
setStatus('Resolved: ' + msg.name + '.tiwd');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
function navigate(url) {
|
||||
if (!url) return;
|
||||
|
||||
// Clean up the URL
|
||||
url = url.trim();
|
||||
if (!url.includes('://') && !url.startsWith('/')) {
|
||||
if (url.includes('.tiwd')) {
|
||||
url = 'http://localhost:8080/browse/' + url;
|
||||
} else if (url.includes('.')) {
|
||||
url = 'https://' + url;
|
||||
} else {
|
||||
url = 'http://localhost:8080/browse/' + url + '.tiwd';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('addressInput').value = url.replace('http://localhost:8080/browse/', '');
|
||||
document.getElementById('contentFrame').src = url;
|
||||
setStatus('Loading ' + url);
|
||||
|
||||
// Update tab title
|
||||
const activeTab = document.querySelector('.tab.active .tab-title');
|
||||
if (activeTab) {
|
||||
const displayUrl = url.replace('http://localhost:8080/browse/', '').replace('https://', '').replace('http://', '');
|
||||
activeTab.textContent = displayUrl.split('/')[0] || 'New Tab';
|
||||
}
|
||||
}
|
||||
|
||||
function navigateHome() {
|
||||
const homeHtml = `
|
||||
<html>
|
||||
<head><style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, system-ui, sans-serif; background: #0d1117; color: #e6edf3;
|
||||
display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
.home { text-align: center; max-width: 500px; }
|
||||
h1 { font-size: 48px; margin-bottom: 8px; }
|
||||
h1 span { color: #00d4aa; }
|
||||
p { color: #7d8590; font-size: 16px; margin-bottom: 32px; line-height: 1.5; }
|
||||
.suggestions { display: flex; flex-direction: column; gap: 8px; }
|
||||
.suggestion {
|
||||
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
||||
padding: 12px 16px; cursor: pointer; text-align: left; transition: all 0.15s;
|
||||
text-decoration: none; color: #e6edf3; display: block;
|
||||
}
|
||||
.suggestion:hover { border-color: #00d4aa; background: #1c2333; }
|
||||
.suggestion .name { color: #00d4aa; font-weight: 600; }
|
||||
.suggestion .desc { color: #7d8590; font-size: 12px; margin-top: 2px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="home">
|
||||
<h1>T<span>IWD</span></h1>
|
||||
<p>The Internet We Deserve<br>Type a .tiwd address above to browse the decentralized web.</p>
|
||||
<div class="suggestions">
|
||||
<a class="suggestion" href="http://localhost:8080/browse/server.tiwd">
|
||||
<div class="name">server.tiwd</div>
|
||||
<div class="desc">The TIWD network's first server node</div>
|
||||
</a>
|
||||
<a class="suggestion" href="http://localhost:8080/chat">
|
||||
<div class="name">Chat</div>
|
||||
<div class="desc">Open the encrypted chat interface</div>
|
||||
</a>
|
||||
<a class="suggestion" href="http://localhost:8080/site/">
|
||||
<div class="name">Your Site</div>
|
||||
<div class="desc">View your hosted .tiwd website</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
const frame = document.getElementById('contentFrame');
|
||||
frame.srcdoc = homeHtml;
|
||||
document.getElementById('addressInput').value = '';
|
||||
setStatus('Ready');
|
||||
}
|
||||
|
||||
// ── Chat ──
|
||||
function toggleChat() {
|
||||
chatOpen = !chatOpen;
|
||||
document.getElementById('chatSidebar').classList.toggle('open', chatOpen);
|
||||
}
|
||||
|
||||
function addChatMsg(type, sender, text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg ' + type;
|
||||
if (sender && type !== 'system') {
|
||||
div.innerHTML = '<div class="sender">' + escapeHtml(sender) + '</div>' + escapeHtml(text);
|
||||
} else {
|
||||
div.textContent = text;
|
||||
}
|
||||
document.getElementById('chatMessages').appendChild(div);
|
||||
div.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function sendChat() {
|
||||
const input = document.getElementById('chatInput');
|
||||
const text = input.value.trim();
|
||||
if (!text || !ws) return;
|
||||
|
||||
if (text.startsWith('/msg ')) {
|
||||
const parts = text.substring(5).split(' ');
|
||||
const name = parts.shift();
|
||||
ws.send(JSON.stringify({ action: 'msg', name, text: parts.join(' ') }));
|
||||
} else if (text.startsWith('/dm ')) {
|
||||
const parts = text.substring(4).split(' ');
|
||||
const peer = parts.shift();
|
||||
ws.send(JSON.stringify({ action: 'dm', peer_id: peer, text: parts.join(' ') }));
|
||||
} else {
|
||||
ws.send(JSON.stringify({ action: 'chat', text }));
|
||||
addChatMsg('self', null, text);
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
function toggleIdentity() {
|
||||
identityOpen = !identityOpen;
|
||||
document.getElementById('identityPanel').classList.toggle('open', identityOpen);
|
||||
}
|
||||
|
||||
function registerName() {
|
||||
const input = document.getElementById('registerInput');
|
||||
const name = input.value.trim().toLowerCase().replace('.tiwd', '');
|
||||
if (!name || !ws) return;
|
||||
ws.send(JSON.stringify({ action: 'register', name }));
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function setStatus(text) {
|
||||
document.getElementById('statusText').textContent = text;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Event Listeners ──
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Address bar
|
||||
document.getElementById('addressInput').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') navigate(e.target.value);
|
||||
});
|
||||
document.getElementById('btnGo').addEventListener('click', () => {
|
||||
navigate(document.getElementById('addressInput').value);
|
||||
});
|
||||
|
||||
// Navigation
|
||||
document.getElementById('btnBack').addEventListener('click', () => {
|
||||
document.getElementById('contentFrame').contentWindow?.history.back();
|
||||
});
|
||||
document.getElementById('btnForward').addEventListener('click', () => {
|
||||
document.getElementById('contentFrame').contentWindow?.history.forward();
|
||||
});
|
||||
document.getElementById('btnRefresh').addEventListener('click', () => {
|
||||
const frame = document.getElementById('contentFrame');
|
||||
if (frame.src) frame.src = frame.src;
|
||||
});
|
||||
|
||||
// Chat
|
||||
document.getElementById('btnChat').addEventListener('click', toggleChat);
|
||||
document.getElementById('btnCloseChat').addEventListener('click', toggleChat);
|
||||
document.getElementById('chatInput').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') sendChat();
|
||||
});
|
||||
document.getElementById('btnSendChat').addEventListener('click', sendChat);
|
||||
|
||||
// Identity
|
||||
document.getElementById('btnIdentity').addEventListener('click', toggleIdentity);
|
||||
document.getElementById('btnRegister').addEventListener('click', registerName);
|
||||
|
||||
// Focus address bar on Ctrl+L
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
e.preventDefault();
|
||||
document.getElementById('addressInput').focus();
|
||||
document.getElementById('addressInput').select();
|
||||
}
|
||||
});
|
||||
|
||||
// Show home page
|
||||
navigateHome();
|
||||
|
||||
// Connect to TIWD node
|
||||
connect();
|
||||
});
|
||||
387
app/src/style.css
Normal file
387
app/src/style.css
Normal file
@ -0,0 +1,387 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #1e1e2e;
|
||||
--bg-darker: #181825;
|
||||
--bg-lighter: #313244;
|
||||
--surface: #45475a;
|
||||
--text: #cdd6f4;
|
||||
--text-dim: #6c7086;
|
||||
--accent: #00d4aa;
|
||||
--accent-hover: #00b894;
|
||||
--border: #313244;
|
||||
--red: #f38ba8;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Top Bar ── */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-darker);
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.topbar-left, .topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--bg-lighter);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.address-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.address-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.address-bar input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
padding: 8px 0;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.address-bar input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.address-bar:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.go-btn {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.go-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── Tab Bar ── */
|
||||
.tabbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-darker);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 8px;
|
||||
gap: 2px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
max-width: 200px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover { background: var(--bg-lighter); color: var(--text); }
|
||||
.tab.active { background: var(--bg); color: var(--text); }
|
||||
|
||||
.tab-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover { color: var(--red); }
|
||||
|
||||
.tab-new {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-new:hover { background: var(--bg-lighter); color: var(--text); }
|
||||
|
||||
/* ── Content Area ── */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-view {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.browser-view iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
/* ── Chat Sidebar ── */
|
||||
.chat-sidebar {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-darker);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.chat-sidebar.open {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.chat-header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
max-width: 90%;
|
||||
animation: fadeIn 0.15s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.chat-msg.self {
|
||||
align-self: flex-end;
|
||||
background: #1a3a2a;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.chat-msg.other {
|
||||
align-self: flex-start;
|
||||
background: var(--bg-lighter);
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
.chat-msg .sender {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-msg.system {
|
||||
align-self: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input-area input:focus { border-color: var(--accent); }
|
||||
|
||||
/* ── Status Bar ── */
|
||||
.statusbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-darker);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.statusbar-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Identity Panel ── */
|
||||
.identity-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 45px;
|
||||
right: 12px;
|
||||
background: var(--bg-darker);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
width: 320px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.identity-panel.open { display: block; }
|
||||
|
||||
.identity-panel h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.id-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.id-field label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.id-field div {
|
||||
font-size: 14px;
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mono { font-family: monospace; font-size: 11px !important; }
|
||||
|
||||
.id-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.id-actions input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
13
app/vite.config.js
Normal file
13
app/vite.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
18
crates/tiwd-chat/Cargo.toml
Normal file
18
crates/tiwd-chat/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "tiwd-chat"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Encrypted chat protocol for TIWD"
|
||||
|
||||
[dependencies]
|
||||
tiwd-core = { path = "../tiwd-core" }
|
||||
tiwd-trust = { path = "../tiwd-trust" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
libp2p = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
3
crates/tiwd-chat/src/lib.rs
Normal file
3
crates/tiwd-chat/src/lib.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod protocol;
|
||||
|
||||
pub use protocol::{ChatMessage, ChatRoom, ChatService};
|
||||
242
crates/tiwd-chat/src/protocol.rs
Normal file
242
crates/tiwd-chat/src/protocol.rs
Normal file
@ -0,0 +1,242 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use libp2p::PeerId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tiwd_core::network::{DirectMessage, NetworkCommand};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
// ── Chat Messages ──────────────────────────────────────────────────
|
||||
|
||||
/// A chat message on the TIWD network.
|
||||
/// All messages are end-to-end encrypted by default.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// Unique message ID (hash of content + timestamp + sender).
|
||||
pub id: String,
|
||||
/// Sender's peer ID.
|
||||
pub sender: String,
|
||||
/// Display name of sender (optional, untrusted).
|
||||
pub sender_name: Option<String>,
|
||||
/// The message content (plaintext after decryption).
|
||||
pub content: ChatContent,
|
||||
/// When the message was created.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Signature by the sender's key.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ChatContent {
|
||||
Text(String),
|
||||
File {
|
||||
name: String,
|
||||
size: u64,
|
||||
/// Content hash for retrieval from DHT/peers.
|
||||
hash: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Chat Room ──────────────────────────────────────────────────────
|
||||
|
||||
/// A chat room — either a direct conversation or a group.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRoom {
|
||||
/// Room identifier (hash of participants for DM, chosen name for group).
|
||||
pub id: String,
|
||||
/// Room display name.
|
||||
pub name: String,
|
||||
/// Type of room.
|
||||
pub room_type: RoomType,
|
||||
/// Members of this room.
|
||||
pub members: Vec<String>,
|
||||
/// GossipSub topic for group rooms.
|
||||
pub topic: Option<String>,
|
||||
/// When this room was created.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum RoomType {
|
||||
/// Direct message between two peers. Uses request-response.
|
||||
Direct,
|
||||
/// Group chat. Uses GossipSub pub/sub.
|
||||
Group,
|
||||
}
|
||||
|
||||
// ── Chat Service ───────────────────────────────────────────────────
|
||||
|
||||
/// The chat service manages conversations and message routing.
|
||||
/// It sits on top of the network layer and uses it to send/receive.
|
||||
pub struct ChatService {
|
||||
/// Channel to send commands to the network layer.
|
||||
command_tx: mpsc::Sender<NetworkCommand>,
|
||||
/// Active chat rooms.
|
||||
rooms: HashMap<String, ChatRoom>,
|
||||
/// Our peer ID.
|
||||
local_peer_id: String,
|
||||
/// Message history (in-memory for now, will be persisted later).
|
||||
messages: HashMap<String, Vec<ChatMessage>>,
|
||||
}
|
||||
|
||||
impl ChatService {
|
||||
pub fn new(command_tx: mpsc::Sender<NetworkCommand>, local_peer_id: PeerId) -> Self {
|
||||
Self {
|
||||
command_tx,
|
||||
rooms: HashMap::new(),
|
||||
local_peer_id: local_peer_id.to_string(),
|
||||
messages: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a direct message to another peer.
|
||||
pub async fn send_direct_message(
|
||||
&mut self,
|
||||
peer: PeerId,
|
||||
text: String,
|
||||
) -> anyhow::Result<ChatMessage> {
|
||||
let msg = ChatMessage {
|
||||
id: Self::generate_message_id(&self.local_peer_id, &text),
|
||||
sender: self.local_peer_id.clone(),
|
||||
sender_name: None,
|
||||
content: ChatContent::Text(text),
|
||||
timestamp: Utc::now(),
|
||||
signature: Vec::new(), // TODO: sign with node identity
|
||||
};
|
||||
|
||||
let payload = serde_json::to_vec(&msg)?;
|
||||
|
||||
let dm = DirectMessage {
|
||||
from: self.local_peer_id.clone(),
|
||||
payload,
|
||||
timestamp: msg.timestamp.timestamp(),
|
||||
signature: msg.signature.clone(),
|
||||
msg_type: "dm".to_string(),
|
||||
};
|
||||
|
||||
self.command_tx
|
||||
.send(NetworkCommand::SendDirectMessage {
|
||||
peer,
|
||||
message: dm,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Store in local history
|
||||
let room_id = Self::direct_room_id(&self.local_peer_id, &peer.to_string());
|
||||
self.messages
|
||||
.entry(room_id)
|
||||
.or_default()
|
||||
.push(msg.clone());
|
||||
|
||||
info!("Sent DM to {}", peer);
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Send a message to a group chat room via GossipSub.
|
||||
pub async fn send_group_message(
|
||||
&mut self,
|
||||
room_id: &str,
|
||||
text: String,
|
||||
) -> anyhow::Result<ChatMessage> {
|
||||
let room = self
|
||||
.rooms
|
||||
.get(room_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
|
||||
let topic = room
|
||||
.topic
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Room has no gossip topic"))?
|
||||
.clone();
|
||||
|
||||
let msg = ChatMessage {
|
||||
id: Self::generate_message_id(&self.local_peer_id, &text),
|
||||
sender: self.local_peer_id.clone(),
|
||||
sender_name: None,
|
||||
content: ChatContent::Text(text),
|
||||
timestamp: Utc::now(),
|
||||
signature: Vec::new(), // TODO: sign with node identity
|
||||
};
|
||||
|
||||
let payload = serde_json::to_vec(&msg)?;
|
||||
|
||||
self.command_tx
|
||||
.send(NetworkCommand::PublishGossip {
|
||||
topic,
|
||||
data: payload,
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.messages
|
||||
.entry(room_id.to_string())
|
||||
.or_default()
|
||||
.push(msg.clone());
|
||||
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Create or join a group chat room.
|
||||
pub async fn join_group(&mut self, name: String) -> anyhow::Result<ChatRoom> {
|
||||
let topic = format!("tiwd/chat/group/{}", name);
|
||||
let room_id = format!("group:{}", name);
|
||||
|
||||
let room = ChatRoom {
|
||||
id: room_id.clone(),
|
||||
name: name.clone(),
|
||||
room_type: RoomType::Group,
|
||||
members: vec![self.local_peer_id.clone()],
|
||||
topic: Some(topic.clone()),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
// Subscribe to the gossipsub topic
|
||||
self.command_tx
|
||||
.send(NetworkCommand::Subscribe { topic })
|
||||
.await?;
|
||||
|
||||
self.rooms.insert(room_id, room.clone());
|
||||
info!("Joined group: {}", name);
|
||||
Ok(room)
|
||||
}
|
||||
|
||||
/// Handle an incoming chat message (from network events).
|
||||
pub fn handle_incoming_message(
|
||||
&mut self,
|
||||
room_id: &str,
|
||||
message: ChatMessage,
|
||||
) {
|
||||
self.messages
|
||||
.entry(room_id.to_string())
|
||||
.or_default()
|
||||
.push(message);
|
||||
}
|
||||
|
||||
/// Get message history for a room.
|
||||
pub fn get_messages(&self, room_id: &str) -> &[ChatMessage] {
|
||||
self.messages
|
||||
.get(room_id)
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// List all active rooms.
|
||||
pub fn list_rooms(&self) -> Vec<&ChatRoom> {
|
||||
self.rooms.values().collect()
|
||||
}
|
||||
|
||||
fn generate_message_id(sender: &str, content: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let now = Utc::now().timestamp_nanos_opt().unwrap_or(0);
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(sender.as_bytes());
|
||||
hasher.update(content.as_bytes());
|
||||
hasher.update(now.to_le_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn direct_room_id(a: &str, b: &str) -> String {
|
||||
let mut peers = [a, b];
|
||||
peers.sort();
|
||||
format!("dm:{}:{}", peers[0], peers[1])
|
||||
}
|
||||
}
|
||||
20
crates/tiwd-core/Cargo.toml
Normal file
20
crates/tiwd-core/Cargo.toml
Normal file
@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "tiwd-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Core networking layer for TIWD - The Internet We Deserve"
|
||||
|
||||
[dependencies]
|
||||
libp2p = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
61
crates/tiwd-core/src/config.rs
Normal file
61
crates/tiwd-core/src/config.rs
Normal file
@ -0,0 +1,61 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Bootstrap peers — hardcoded entry points into the TIWD network.
|
||||
/// These are not servers. They are just regular nodes that volunteer to be
|
||||
/// the first point of contact. Anyone can run a bootstrap node.
|
||||
pub const DEFAULT_BOOTSTRAP_PEERS: &[&str] = &[
|
||||
// These will be populated as the network grows.
|
||||
// For now, nodes discover each other via mDNS on local networks.
|
||||
];
|
||||
|
||||
/// Configuration for a TIWD node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeConfig {
|
||||
/// TCP port to listen on.
|
||||
pub listen_port: u16,
|
||||
/// Directory for storing identity keys and local data.
|
||||
pub data_dir: PathBuf,
|
||||
/// Additional bootstrap peers to connect to on startup.
|
||||
pub bootstrap_peers: Vec<String>,
|
||||
/// Whether to enable mDNS local discovery.
|
||||
pub enable_mdns: bool,
|
||||
/// Display name for this node (optional, not an identity).
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for NodeConfig {
|
||||
fn default() -> Self {
|
||||
let data_dir = directories::ProjectDirs::from("net", "tiwd", "tiwd-node")
|
||||
.map(|dirs| dirs.data_dir().to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from(".tiwd"));
|
||||
|
||||
Self {
|
||||
listen_port: 0, // OS picks a free port
|
||||
data_dir,
|
||||
bootstrap_peers: Vec::new(),
|
||||
enable_mdns: true,
|
||||
display_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeConfig {
|
||||
/// Load config from file, or return defaults.
|
||||
pub fn load(path: &Path) -> Self {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save config to file.
|
||||
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
72
crates/tiwd-core/src/content.rs
Normal file
72
crates/tiwd-core/src/content.rs
Normal file
@ -0,0 +1,72 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A request for content from a TIWD node.
|
||||
/// This is the decentralized web protocol — one node asks another
|
||||
/// for a file, and the response comes back peer-to-peer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentRequest {
|
||||
/// The path being requested (e.g., "/index.html", "/about.html").
|
||||
pub path: String,
|
||||
/// The TIWD name of the site (e.g., "thefounder").
|
||||
pub site_name: String,
|
||||
}
|
||||
|
||||
/// A response containing the requested content.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentResponse {
|
||||
/// HTTP-like status code (200 = OK, 404 = not found).
|
||||
pub status: u16,
|
||||
/// MIME type of the content.
|
||||
pub content_type: String,
|
||||
/// The raw content bytes.
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ContentResponse {
|
||||
pub fn ok(body: Vec<u8>, content_type: &str) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
content_type: content_type.to_string(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found() -> Self {
|
||||
Self {
|
||||
status: 404,
|
||||
content_type: "text/html".to_string(),
|
||||
body: b"<h1>404 - Not Found</h1><p>This page does not exist on this node.</p>".to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Guess MIME type from file extension.
|
||||
pub fn mime_for_path(path: &str) -> &'static str {
|
||||
if path.ends_with(".html") || path.ends_with(".htm") {
|
||||
"text/html"
|
||||
} else if path.ends_with(".css") {
|
||||
"text/css"
|
||||
} else if path.ends_with(".js") {
|
||||
"application/javascript"
|
||||
} else if path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else if path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if path.ends_with(".gif") {
|
||||
"image/gif"
|
||||
} else if path.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else if path.ends_with(".ico") {
|
||||
"image/x-icon"
|
||||
} else if path.ends_with(".txt") {
|
||||
"text/plain"
|
||||
} else if path.ends_with(".xml") {
|
||||
"application/xml"
|
||||
} else if path.ends_with(".wasm") {
|
||||
"application/wasm"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
86
crates/tiwd-core/src/identity.rs
Normal file
86
crates/tiwd-core/src/identity.rs
Normal file
@ -0,0 +1,86 @@
|
||||
use anyhow::{Context, Result};
|
||||
use libp2p::identity::{self, ed25519, Keypair};
|
||||
use libp2p::PeerId;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::info;
|
||||
|
||||
/// A node's cryptographic identity on the TIWD network.
|
||||
/// Generated once, stored locally, never shared. The private key never leaves the device.
|
||||
/// Trust is bound to this identity and cannot be transferred.
|
||||
pub struct NodeIdentity {
|
||||
keypair: Keypair,
|
||||
peer_id: PeerId,
|
||||
identity_path: PathBuf,
|
||||
}
|
||||
|
||||
impl NodeIdentity {
|
||||
/// Load existing identity from disk, or generate a new one.
|
||||
pub fn load_or_generate(data_dir: &Path) -> Result<Self> {
|
||||
let identity_path = data_dir.join("identity.key");
|
||||
|
||||
let keypair = if identity_path.exists() {
|
||||
info!("Loading existing identity from {:?}", identity_path);
|
||||
let bytes = fs::read(&identity_path)
|
||||
.context("Failed to read identity key")?;
|
||||
let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut bytes.clone())
|
||||
.context("Failed to deserialize identity key")?;
|
||||
Keypair::from(ed25519_kp)
|
||||
} else {
|
||||
info!("Generating new node identity");
|
||||
fs::create_dir_all(data_dir)
|
||||
.context("Failed to create data directory")?;
|
||||
let ed25519_kp = ed25519::Keypair::generate();
|
||||
let bytes = ed25519_kp.to_bytes();
|
||||
fs::write(&identity_path, bytes)
|
||||
.context("Failed to write identity key")?;
|
||||
// Restrict permissions on Unix systems
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&identity_path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Keypair::from(ed25519_kp)
|
||||
};
|
||||
|
||||
let peer_id = PeerId::from_public_key(&keypair.public());
|
||||
|
||||
info!("Node identity: {}", peer_id);
|
||||
|
||||
Ok(Self {
|
||||
keypair,
|
||||
peer_id,
|
||||
identity_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn keypair(&self) -> &Keypair {
|
||||
&self.keypair
|
||||
}
|
||||
|
||||
pub fn peer_id(&self) -> PeerId {
|
||||
self.peer_id
|
||||
}
|
||||
|
||||
pub fn identity_path(&self) -> &Path {
|
||||
&self.identity_path
|
||||
}
|
||||
|
||||
/// Sign arbitrary data with this node's private key.
|
||||
/// Used for trust vouches, moderation votes, and message authentication.
|
||||
pub fn sign(&self, data: &[u8]) -> Result<Vec<u8>> {
|
||||
match self.keypair.sign(data) {
|
||||
Ok(sig) => Ok(sig),
|
||||
Err(e) => anyhow::bail!("Signing failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a signature from another peer.
|
||||
pub fn verify(peer_id: &PeerId, data: &[u8], signature: &[u8]) -> bool {
|
||||
// Verification requires the public key, which we'd retrieve from the DHT
|
||||
// For now, this is a placeholder that will be filled in when we implement
|
||||
// the full trust verification pipeline
|
||||
let _ = (peer_id, data, signature);
|
||||
false
|
||||
}
|
||||
}
|
||||
12
crates/tiwd-core/src/lib.rs
Normal file
12
crates/tiwd-core/src/lib.rs
Normal file
@ -0,0 +1,12 @@
|
||||
pub mod config;
|
||||
pub mod content;
|
||||
pub mod identity;
|
||||
pub mod names;
|
||||
pub mod network;
|
||||
|
||||
pub use config::NodeConfig;
|
||||
pub use identity::NodeIdentity;
|
||||
pub use names::{NameCache, NameRecord};
|
||||
pub use network::{
|
||||
DirectMessage, NetworkCommand, NetworkEvent, NodeNetwork, TiwdBehaviour,
|
||||
};
|
||||
114
crates/tiwd-core/src/names.rs
Normal file
114
crates/tiwd-core/src/names.rs
Normal file
@ -0,0 +1,114 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use libp2p::PeerId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
/// A TIWD Name record — maps a human-readable name to a PeerId.
|
||||
/// Stored in the Kademlia DHT, signed by the registrant.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NameRecord {
|
||||
/// The human-readable name (e.g., "alice").
|
||||
pub name: String,
|
||||
/// The PeerId this name resolves to.
|
||||
pub peer_id: String,
|
||||
/// When this name was registered.
|
||||
pub registered: DateTime<Utc>,
|
||||
/// When this record was last refreshed.
|
||||
pub last_refreshed: DateTime<Utc>,
|
||||
/// Signature by the registrant's Ed25519 key.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
impl NameRecord {
|
||||
/// Create a new name record.
|
||||
pub fn new(name: &str, peer_id: PeerId) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
name: name.to_lowercase(),
|
||||
peer_id: peer_id.to_string(),
|
||||
registered: now,
|
||||
last_refreshed: now,
|
||||
signature: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the DHT key for a TIWD name.
|
||||
/// All names are stored under: sha256("tiwd/name/{name}")
|
||||
pub fn dht_key(name: &str) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("tiwd/name/{}", name.to_lowercase()).as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Serialize this record for DHT storage.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(self)?)
|
||||
}
|
||||
|
||||
/// Deserialize a record from DHT storage.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
|
||||
Ok(serde_json::from_slice(bytes)?)
|
||||
}
|
||||
|
||||
/// Validate the name format.
|
||||
pub fn is_valid_name(name: &str) -> bool {
|
||||
let name = name.to_lowercase();
|
||||
// 3-32 characters, alphanumeric and hyphens, can't start/end with hyphen
|
||||
if name.len() < 3 || name.len() > 32 {
|
||||
return false;
|
||||
}
|
||||
if name.starts_with('-') || name.ends_with('-') {
|
||||
return false;
|
||||
}
|
||||
name.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
}
|
||||
}
|
||||
|
||||
/// Local name cache — stores resolved names to avoid repeated DHT lookups.
|
||||
pub struct NameCache {
|
||||
cache: HashMap<String, NameRecord>,
|
||||
/// Names registered by this node.
|
||||
my_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl NameCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: HashMap::new(),
|
||||
my_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a resolved name record.
|
||||
pub fn insert(&mut self, record: NameRecord) {
|
||||
info!("Cached name: {} → {}", record.name, record.peer_id);
|
||||
self.cache.insert(record.name.clone(), record);
|
||||
}
|
||||
|
||||
/// Look up a cached name.
|
||||
pub fn get(&self, name: &str) -> Option<&NameRecord> {
|
||||
self.cache.get(&name.to_lowercase())
|
||||
}
|
||||
|
||||
/// Record that this node registered a name.
|
||||
pub fn add_my_name(&mut self, name: String) {
|
||||
if !self.my_names.contains(&name) {
|
||||
self.my_names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get names registered by this node.
|
||||
pub fn my_names(&self) -> &[String] {
|
||||
&self.my_names
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NameCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
497
crates/tiwd-core/src/network.rs
Normal file
497
crates/tiwd-core/src/network.rs
Normal file
@ -0,0 +1,497 @@
|
||||
use anyhow::{Context, Result};
|
||||
use futures::StreamExt;
|
||||
use libp2p::gossipsub::{self, IdentTopic, MessageAuthenticity};
|
||||
use libp2p::kad::store::MemoryStore;
|
||||
use libp2p::kad::{self, Mode};
|
||||
use libp2p::mdns;
|
||||
use libp2p::request_response::{self, cbor, ProtocolSupport};
|
||||
use libp2p::swarm::{NetworkBehaviour, SwarmEvent};
|
||||
use libp2p::{identify, Multiaddr, PeerId, StreamProtocol, Swarm, SwarmBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::identity::NodeIdentity;
|
||||
|
||||
// ── Protocol Messages ──────────────────────────────────────────────
|
||||
|
||||
/// Direct message between two peers (request-response pattern).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DirectMessage {
|
||||
pub from: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub timestamp: i64,
|
||||
pub signature: Vec<u8>,
|
||||
/// Message type: "dm" for chat, "content_request" for web content
|
||||
#[serde(default = "default_msg_type")]
|
||||
pub msg_type: String,
|
||||
}
|
||||
|
||||
fn default_msg_type() -> String {
|
||||
"dm".to_string()
|
||||
}
|
||||
|
||||
/// Response to a direct message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DirectMessageResponse {
|
||||
pub accepted: bool,
|
||||
/// Response payload — used for content responses
|
||||
#[serde(default)]
|
||||
pub payload: Vec<u8>,
|
||||
/// Content type for web responses
|
||||
#[serde(default)]
|
||||
pub content_type: String,
|
||||
/// HTTP-like status code
|
||||
#[serde(default)]
|
||||
pub status: u16,
|
||||
}
|
||||
|
||||
// ── Network Behaviour ──────────────────────────────────────────────
|
||||
|
||||
/// Combined network behaviour for a TIWD node.
|
||||
/// Every node runs ALL of these protocols simultaneously.
|
||||
#[derive(NetworkBehaviour)]
|
||||
pub struct TiwdBehaviour {
|
||||
/// Kademlia DHT — decentralized peer discovery and data storage.
|
||||
/// Every node is part of the DHT. No central directory.
|
||||
pub kademlia: kad::Behaviour<MemoryStore>,
|
||||
|
||||
/// mDNS — automatic discovery of peers on the local network.
|
||||
/// Zero configuration, works offline.
|
||||
pub mdns: mdns::tokio::Behaviour,
|
||||
|
||||
/// GossipSub — pub/sub messaging for group chats and network announcements.
|
||||
/// Messages propagate through the mesh without a central broker.
|
||||
pub gossipsub: gossipsub::Behaviour,
|
||||
|
||||
/// Identify — exchange node information when connecting to a new peer.
|
||||
pub identify: identify::Behaviour,
|
||||
|
||||
/// Request-Response — direct encrypted messages between two peers.
|
||||
pub direct_msg: cbor::Behaviour<DirectMessage, DirectMessageResponse>,
|
||||
}
|
||||
|
||||
// ── Network Events ─────────────────────────────────────────────────
|
||||
|
||||
/// Events emitted by the network layer to the application.
|
||||
#[derive(Debug)]
|
||||
pub enum NetworkEvent {
|
||||
/// A new peer was discovered on the network.
|
||||
PeerDiscovered(PeerId),
|
||||
/// A peer disconnected.
|
||||
PeerDisconnected(PeerId),
|
||||
/// Received a direct message from a peer.
|
||||
DirectMessage {
|
||||
from: PeerId,
|
||||
message: DirectMessage,
|
||||
},
|
||||
/// Received a response to a direct message we sent.
|
||||
DirectMessageResponse {
|
||||
from: PeerId,
|
||||
response: DirectMessageResponse,
|
||||
},
|
||||
/// Received a gossipsub message (group chat, announcements, etc).
|
||||
GossipMessage {
|
||||
topic: String,
|
||||
from: Option<PeerId>,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// A value was found in the DHT.
|
||||
DhtValueFound {
|
||||
key: Vec<u8>,
|
||||
value: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Network Commands ───────────────────────────────────────────────
|
||||
|
||||
/// Commands sent from the application to the network layer.
|
||||
#[derive(Debug)]
|
||||
pub enum NetworkCommand {
|
||||
/// Send a direct message to a specific peer.
|
||||
SendDirectMessage {
|
||||
peer: PeerId,
|
||||
message: DirectMessage,
|
||||
},
|
||||
/// Publish a message to a gossipsub topic.
|
||||
PublishGossip {
|
||||
topic: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Subscribe to a gossipsub topic.
|
||||
Subscribe {
|
||||
topic: String,
|
||||
},
|
||||
/// Store a key-value pair in the DHT.
|
||||
DhtPut {
|
||||
key: Vec<u8>,
|
||||
value: Vec<u8>,
|
||||
},
|
||||
/// Look up a value in the DHT.
|
||||
DhtGet {
|
||||
key: Vec<u8>,
|
||||
},
|
||||
/// Connect to a specific peer address (bootstrap).
|
||||
Dial {
|
||||
addr: Multiaddr,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Node Network ───────────────────────────────────────────────────
|
||||
|
||||
pub struct NodeNetwork {
|
||||
swarm: Swarm<TiwdBehaviour>,
|
||||
event_tx: mpsc::Sender<NetworkEvent>,
|
||||
command_rx: mpsc::Receiver<NetworkCommand>,
|
||||
known_peers: HashSet<PeerId>,
|
||||
}
|
||||
|
||||
impl NodeNetwork {
|
||||
/// Create and start a new TIWD network node.
|
||||
pub fn new(
|
||||
identity: &NodeIdentity,
|
||||
listen_port: u16,
|
||||
event_tx: mpsc::Sender<NetworkEvent>,
|
||||
command_rx: mpsc::Receiver<NetworkCommand>,
|
||||
) -> Result<Self> {
|
||||
let peer_id = identity.peer_id();
|
||||
|
||||
// Build the libp2p swarm with all protocols
|
||||
let swarm = SwarmBuilder::with_existing_identity(identity.keypair().clone())
|
||||
.with_tokio()
|
||||
.with_tcp(
|
||||
libp2p::tcp::Config::default(),
|
||||
libp2p::noise::Config::new,
|
||||
libp2p::yamux::Config::default,
|
||||
)
|
||||
.context("Failed to configure TCP transport")?
|
||||
.with_dns()
|
||||
.context("Failed to configure DNS")?
|
||||
.with_behaviour(|key| {
|
||||
// Kademlia DHT
|
||||
let store = MemoryStore::new(key.public().to_peer_id());
|
||||
let mut kad_config = kad::Config::new(
|
||||
StreamProtocol::new("/tiwd/kad/1.0.0"),
|
||||
);
|
||||
kad_config.set_query_timeout(Duration::from_secs(60));
|
||||
let mut kademlia = kad::Behaviour::with_config(
|
||||
key.public().to_peer_id(),
|
||||
store,
|
||||
kad_config,
|
||||
);
|
||||
// Every node is a server in the DHT
|
||||
kademlia.set_mode(Some(Mode::Server));
|
||||
|
||||
// mDNS for local discovery
|
||||
let mdns = mdns::tokio::Behaviour::new(
|
||||
mdns::Config::default(),
|
||||
key.public().to_peer_id(),
|
||||
)
|
||||
.expect("Failed to create mDNS behaviour");
|
||||
|
||||
// GossipSub for pub/sub messaging
|
||||
let gossipsub_config = gossipsub::ConfigBuilder::default()
|
||||
.heartbeat_interval(Duration::from_secs(10))
|
||||
.validation_mode(gossipsub::ValidationMode::Strict)
|
||||
.build()
|
||||
.expect("Valid gossipsub config");
|
||||
let gossipsub = gossipsub::Behaviour::new(
|
||||
MessageAuthenticity::Signed(key.clone()),
|
||||
gossipsub_config,
|
||||
)
|
||||
.expect("Failed to create gossipsub behaviour");
|
||||
|
||||
// Identify protocol
|
||||
let identify = identify::Behaviour::new(identify::Config::new(
|
||||
"/tiwd/id/1.0.0".to_string(),
|
||||
key.public(),
|
||||
));
|
||||
|
||||
// Direct messaging (request-response with CBOR encoding)
|
||||
let direct_msg = cbor::Behaviour::new(
|
||||
[(
|
||||
StreamProtocol::new("/tiwd/dm/1.0.0"),
|
||||
ProtocolSupport::Full,
|
||||
)],
|
||||
request_response::Config::default(),
|
||||
);
|
||||
|
||||
Ok(TiwdBehaviour {
|
||||
kademlia,
|
||||
mdns,
|
||||
gossipsub,
|
||||
identify,
|
||||
direct_msg,
|
||||
})
|
||||
})
|
||||
.context("Failed to create network behaviour")?
|
||||
.with_swarm_config(|cfg| {
|
||||
cfg.with_idle_connection_timeout(Duration::from_secs(300))
|
||||
})
|
||||
.build();
|
||||
|
||||
info!("Network node created: {} on port {}", peer_id, listen_port);
|
||||
|
||||
Ok(Self {
|
||||
swarm,
|
||||
event_tx,
|
||||
command_rx,
|
||||
known_peers: HashSet::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start listening and run the network event loop.
|
||||
pub async fn run(&mut self, listen_port: u16) -> Result<()> {
|
||||
// Listen on all interfaces
|
||||
let listen_addr: Multiaddr = format!("/ip4/0.0.0.0/tcp/{}", listen_port)
|
||||
.parse()
|
||||
.context("Invalid listen address")?;
|
||||
self.swarm.listen_on(listen_addr)?;
|
||||
|
||||
info!("TIWD node listening on port {}", listen_port);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Handle swarm events
|
||||
event = self.swarm.select_next_some() => {
|
||||
self.handle_swarm_event(event).await;
|
||||
}
|
||||
// Handle application commands
|
||||
Some(cmd) = self.command_rx.recv() => {
|
||||
self.handle_command(cmd).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_swarm_event(&mut self, event: SwarmEvent<TiwdBehaviourEvent>) {
|
||||
use libp2p::swarm::SwarmEvent::*;
|
||||
|
||||
match event {
|
||||
NewListenAddr { address, .. } => {
|
||||
info!("Listening on {}", address);
|
||||
}
|
||||
|
||||
Behaviour(TiwdBehaviourEvent::Mdns(mdns::Event::Discovered(peers))) => {
|
||||
for (peer_id, addr) in peers {
|
||||
info!("mDNS discovered peer: {} at {}", peer_id, addr);
|
||||
self.swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
|
||||
if self.known_peers.insert(peer_id) {
|
||||
let _ = self.event_tx.send(NetworkEvent::PeerDiscovered(peer_id)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behaviour(TiwdBehaviourEvent::Mdns(mdns::Event::Expired(peers))) => {
|
||||
for (peer_id, _) in peers {
|
||||
debug!("mDNS peer expired: {}", peer_id);
|
||||
}
|
||||
}
|
||||
|
||||
Behaviour(TiwdBehaviourEvent::Kademlia(kad::Event::RoutingUpdated {
|
||||
peer, ..
|
||||
})) => {
|
||||
debug!("Kademlia routing updated: {}", peer);
|
||||
if self.known_peers.insert(peer) {
|
||||
let _ = self.event_tx.send(NetworkEvent::PeerDiscovered(peer)).await;
|
||||
}
|
||||
}
|
||||
|
||||
Behaviour(TiwdBehaviourEvent::Kademlia(
|
||||
kad::Event::OutboundQueryProgressed { result, .. },
|
||||
)) => {
|
||||
if let kad::QueryResult::GetRecord(Ok(kad::GetRecordOk::FoundRecord(
|
||||
kad::PeerRecord { record, .. },
|
||||
))) = result
|
||||
{
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(NetworkEvent::DhtValueFound {
|
||||
key: record.key.to_vec(),
|
||||
value: record.value,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Behaviour(TiwdBehaviourEvent::Gossipsub(gossipsub::Event::Message {
|
||||
propagation_source,
|
||||
message,
|
||||
..
|
||||
})) => {
|
||||
let topic = message.topic.to_string();
|
||||
debug!("Gossip message on topic '{}' from {:?}", topic, propagation_source);
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(NetworkEvent::GossipMessage {
|
||||
topic,
|
||||
from: message.source,
|
||||
data: message.data,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Behaviour(TiwdBehaviourEvent::DirectMsg(
|
||||
request_response::Event::Message { peer, message },
|
||||
)) => {
|
||||
match message {
|
||||
request_response::Message::Request {
|
||||
request, channel, ..
|
||||
} => {
|
||||
if request.msg_type == "content_request" {
|
||||
// Content request — serve from local site directory
|
||||
info!("Content request from {} for {:?}", peer, String::from_utf8_lossy(&request.payload));
|
||||
let path = String::from_utf8_lossy(&request.payload).to_string();
|
||||
let site_dir = std::env::var("TIWD_SITE_DIR").unwrap_or_else(|_| {
|
||||
format!("{}/site", std::env::var("TIWD_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
|
||||
});
|
||||
let mut file_path = path.trim_start_matches('/').to_string();
|
||||
if file_path.is_empty() { file_path = "index.html".to_string(); }
|
||||
// Security: block traversal
|
||||
if file_path.contains("..") {
|
||||
let _ = self.swarm.behaviour_mut().direct_msg
|
||||
.send_response(channel, DirectMessageResponse {
|
||||
accepted: false, payload: b"403 Forbidden".to_vec(),
|
||||
content_type: "text/plain".to_string(), status: 403,
|
||||
});
|
||||
} else {
|
||||
let full_path = std::path::PathBuf::from(&site_dir).join(&file_path);
|
||||
match std::fs::read(&full_path) {
|
||||
Ok(body) => {
|
||||
let mime = crate::content::ContentResponse::mime_for_path(&file_path);
|
||||
info!("Serving {} ({}, {} bytes)", file_path, mime, body.len());
|
||||
let _ = self.swarm.behaviour_mut().direct_msg
|
||||
.send_response(channel, DirectMessageResponse {
|
||||
accepted: true, payload: body,
|
||||
content_type: mime.to_string(), status: 200,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = self.swarm.behaviour_mut().direct_msg
|
||||
.send_response(channel, DirectMessageResponse {
|
||||
accepted: false,
|
||||
payload: b"<h1>404 - Not Found</h1>".to_vec(),
|
||||
content_type: "text/html".to_string(), status: 404,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular DM
|
||||
info!("Direct message from {}", peer);
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(NetworkEvent::DirectMessage {
|
||||
from: peer,
|
||||
message: request,
|
||||
})
|
||||
.await;
|
||||
let _ = self.swarm.behaviour_mut().direct_msg
|
||||
.send_response(channel, DirectMessageResponse {
|
||||
accepted: true, payload: Vec::new(),
|
||||
content_type: String::new(), status: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
request_response::Message::Response { response, .. } => {
|
||||
debug!("DM response from {}", peer);
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(NetworkEvent::DirectMessageResponse {
|
||||
from: peer,
|
||||
response,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behaviour(TiwdBehaviourEvent::Identify(identify::Event::Received {
|
||||
peer_id,
|
||||
info: identify_info,
|
||||
..
|
||||
})) => {
|
||||
debug!("Identified peer {}: {:?}", peer_id, identify_info.protocol_version);
|
||||
for addr in identify_info.listen_addrs {
|
||||
self.swarm.behaviour_mut().kademlia.add_address(&peer_id, addr);
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionClosed { peer_id, .. } => {
|
||||
if self.known_peers.remove(&peer_id) {
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(NetworkEvent::PeerDisconnected(peer_id))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_command(&mut self, cmd: NetworkCommand) {
|
||||
match cmd {
|
||||
NetworkCommand::SendDirectMessage { peer, message } => {
|
||||
info!("Sending direct message to {}", peer);
|
||||
self.swarm
|
||||
.behaviour_mut()
|
||||
.direct_msg
|
||||
.send_request(&peer, message);
|
||||
}
|
||||
|
||||
NetworkCommand::PublishGossip { topic, data } => {
|
||||
let topic = IdentTopic::new(&topic);
|
||||
if let Err(e) = self.swarm.behaviour_mut().gossipsub.publish(topic, data) {
|
||||
warn!("Failed to publish gossip: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
NetworkCommand::Subscribe { topic } => {
|
||||
let topic = IdentTopic::new(&topic);
|
||||
if let Err(e) = self.swarm.behaviour_mut().gossipsub.subscribe(&topic) {
|
||||
warn!("Failed to subscribe to topic: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
NetworkCommand::DhtPut { key, value } => {
|
||||
let record = kad::Record {
|
||||
key: kad::RecordKey::new(&key),
|
||||
value,
|
||||
publisher: None,
|
||||
expires: None,
|
||||
};
|
||||
if let Err(e) = self
|
||||
.swarm
|
||||
.behaviour_mut()
|
||||
.kademlia
|
||||
.put_record(record, kad::Quorum::One)
|
||||
{
|
||||
warn!("Failed to put DHT record: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
NetworkCommand::DhtGet { key } => {
|
||||
self.swarm
|
||||
.behaviour_mut()
|
||||
.kademlia
|
||||
.get_record(kad::RecordKey::new(&key));
|
||||
}
|
||||
|
||||
NetworkCommand::Dial { addr } => {
|
||||
info!("Dialing {}", addr);
|
||||
if let Err(e) = self.swarm.dial(addr.clone()) {
|
||||
error!("Failed to dial {}: {}", addr, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of known peers.
|
||||
pub fn peer_count(&self) -> usize {
|
||||
self.known_peers.len()
|
||||
}
|
||||
}
|
||||
|
||||
19
crates/tiwd-node/Cargo.toml
Normal file
19
crates/tiwd-node/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "tiwd-node"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "TIWD node binary - run your own node on The Internet We Deserve"
|
||||
|
||||
[dependencies]
|
||||
tiwd-core = { path = "../tiwd-core" }
|
||||
tiwd-trust = { path = "../tiwd-trust" }
|
||||
tiwd-chat = { path = "../tiwd-chat" }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
libp2p = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
warp = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
535
crates/tiwd-node/src/main.rs
Normal file
535
crates/tiwd-node/src/main.rs
Normal file
@ -0,0 +1,535 @@
|
||||
mod proxy;
|
||||
mod site;
|
||||
mod web;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tiwd_core::names::{NameCache, NameRecord};
|
||||
use tiwd_core::network::{DirectMessage, NetworkCommand, NetworkEvent, NodeNetwork};
|
||||
use tiwd_core::{NodeConfig, NodeIdentity};
|
||||
use tiwd_trust::TrustScore;
|
||||
use tokio::io::{self, AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Map PeerIds to display names for friendlier output.
|
||||
struct PeerNames {
|
||||
names: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl PeerNames {
|
||||
fn new() -> Self {
|
||||
Self { names: HashMap::new() }
|
||||
}
|
||||
|
||||
fn set(&mut self, peer_id: &str, name: &str) {
|
||||
self.names.insert(peer_id.to_string(), name.to_string());
|
||||
}
|
||||
|
||||
fn display(&self, peer_id: &str) -> String {
|
||||
if let Some(name) = self.names.get(peer_id) {
|
||||
format!("{} ({})", name, &peer_id[..12])
|
||||
} else {
|
||||
peer_id[..12].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn short(&self, peer_id: &str) -> String {
|
||||
if let Some(name) = self.names.get(peer_id) {
|
||||
name.clone()
|
||||
} else {
|
||||
peer_id[..12].to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "warn".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
println!();
|
||||
println!(" ╔══════════════════════════════════════════════╗");
|
||||
println!(" ║ ║");
|
||||
println!(" ║ TIWD - The Internet We Deserve ║");
|
||||
println!(" ║ Trustless. Decentralized. Yours. ║");
|
||||
println!(" ║ ║");
|
||||
println!(" ╚══════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
// Load or create configuration
|
||||
let mut config = NodeConfig::default();
|
||||
if let Ok(dir) = std::env::var("TIWD_DATA_DIR") {
|
||||
config.data_dir = PathBuf::from(dir);
|
||||
}
|
||||
if let Ok(port) = std::env::var("TIWD_PORT") {
|
||||
config.listen_port = port.parse().unwrap_or(0);
|
||||
}
|
||||
let bootstrap_peer = std::env::var("TIWD_BOOTSTRAP").ok();
|
||||
let auto_reply = std::env::var("TIWD_AUTO_REPLY").ok();
|
||||
let node_name = std::env::var("TIWD_NAME").ok();
|
||||
|
||||
// Load or generate identity
|
||||
let identity = NodeIdentity::load_or_generate(&config.data_dir)?;
|
||||
let peer_id = identity.peer_id();
|
||||
println!(" Your Peer ID: {}", peer_id);
|
||||
|
||||
// Initialize
|
||||
let mut trust = TrustScore::new();
|
||||
let mut name_cache = NameCache::new();
|
||||
let mut peer_names = PeerNames::new();
|
||||
|
||||
println!(" Trust Score: {:.1} | Role: {:?}", trust.total(), trust.role());
|
||||
println!();
|
||||
|
||||
// Create channels
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<NetworkEvent>(256);
|
||||
let (command_tx, command_rx) = mpsc::channel::<NetworkCommand>(256);
|
||||
|
||||
let listen_port = config.listen_port;
|
||||
let mut network = NodeNetwork::new(&identity, listen_port, event_tx, command_rx)?;
|
||||
|
||||
// Auto-register name if provided
|
||||
if let Some(ref name) = node_name {
|
||||
let record = NameRecord::new(name, peer_id);
|
||||
let key = NameRecord::dht_key(name);
|
||||
if let Ok(value) = record.to_bytes() {
|
||||
let _ = command_tx.send(NetworkCommand::DhtPut { key, value }).await;
|
||||
name_cache.add_my_name(name.clone());
|
||||
name_cache.insert(record);
|
||||
println!(" Registered: {}.tiwd", name);
|
||||
|
||||
// Initialize site directory
|
||||
let site_dir = config.data_dir.join("site");
|
||||
if let Err(e) = site::SiteManager::init_site(&site_dir, name) {
|
||||
tracing::warn!("Failed to init site: {}", e);
|
||||
} else {
|
||||
println!(" Your site: {}.tiwd (serving from {:?})", name, site_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(" ┌─────────────────────────────────────────────┐");
|
||||
println!(" │ Commands: │");
|
||||
println!(" │ │");
|
||||
println!(" │ /msg <name> <text> Message by TIWD name │");
|
||||
println!(" │ /dm <peerid> <text> Message by Peer ID │");
|
||||
println!(" │ /register <name> Register a TIWD name │");
|
||||
println!(" │ /resolve <name> Look up a TIWD name │");
|
||||
println!(" │ /names Your registered names │");
|
||||
println!(" │ /peers Connected peers │");
|
||||
println!(" │ /trust Your trust score │");
|
||||
println!(" │ /quit Exit │");
|
||||
println!(" │ │");
|
||||
println!(" │ Or just type to chat in #global │");
|
||||
println!(" └─────────────────────────────────────────────┘");
|
||||
println!();
|
||||
|
||||
// Clone for bootstrap
|
||||
let bootstrap_tx = command_tx.clone();
|
||||
|
||||
// Create shared web state
|
||||
let web_state = Arc::new(RwLock::new(web::WebState {
|
||||
command_tx: command_tx.clone(),
|
||||
peer_id: peer_id.to_string(),
|
||||
name: node_name.clone(),
|
||||
peers: Vec::new(),
|
||||
ws_clients: Vec::new(),
|
||||
pending_content: HashMap::new(),
|
||||
name_cache: HashMap::new(),
|
||||
}));
|
||||
let web_state_events = web_state.clone();
|
||||
|
||||
// Start web UI server
|
||||
let web_port: u16 = std::env::var("TIWD_WEB_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080);
|
||||
let web_state_server = web_state.clone();
|
||||
tokio::spawn(async move {
|
||||
web::start_web_server(web_state_server, web_port).await;
|
||||
});
|
||||
|
||||
// Start .tiwd proxy (allows browser to resolve .tiwd domains directly)
|
||||
let proxy_port: u16 = std::env::var("TIWD_PROXY_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(9080);
|
||||
let web_state_proxy = web_state.clone();
|
||||
tokio::spawn(async move {
|
||||
proxy::start_proxy(web_state_proxy, proxy_port).await;
|
||||
});
|
||||
|
||||
// Spawn network
|
||||
let network_handle = tokio::spawn(async move {
|
||||
if let Err(e) = network.run(listen_port).await {
|
||||
error!("Network error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect to bootstrap peer
|
||||
if let Some(addr_str) = bootstrap_peer {
|
||||
println!(" Connecting to network...");
|
||||
if let Ok(addr) = addr_str.parse::<libp2p::Multiaddr>() {
|
||||
let _ = bootstrap_tx.send(NetworkCommand::Dial { addr }).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn stdin reader
|
||||
let (stdin_tx, mut stdin_rx) = mpsc::channel::<String>(32);
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(io::stdin());
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if stdin_tx.send(line).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pending name resolution for /msg — maps name to pending message
|
||||
let mut pending_msgs: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Connected peers tracking
|
||||
let mut connected_peers: Vec<String> = Vec::new();
|
||||
|
||||
// Main loop
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(event) = event_rx.recv() => {
|
||||
match event {
|
||||
NetworkEvent::PeerDiscovered(peer) => {
|
||||
let pid = peer.to_string();
|
||||
if !connected_peers.contains(&pid) {
|
||||
connected_peers.push(pid.clone());
|
||||
}
|
||||
println!(" [+] Peer connected: {}", peer_names.display(&pid));
|
||||
trust.record_active_day();
|
||||
|
||||
// Notify web UI
|
||||
{
|
||||
let mut ws = web_state_events.write().await;
|
||||
if !ws.peers.contains(&pid) {
|
||||
ws.peers.push(pid.clone());
|
||||
}
|
||||
let msg = serde_json::json!({
|
||||
"type": "peer_discovered",
|
||||
"peer_id": pid,
|
||||
"display": peer_names.display(&pid),
|
||||
});
|
||||
web::broadcast_to_clients(&ws.ws_clients, &msg.to_string()).await;
|
||||
}
|
||||
}
|
||||
NetworkEvent::PeerDisconnected(peer) => {
|
||||
let pid = peer.to_string();
|
||||
connected_peers.retain(|p| p != &pid);
|
||||
println!(" [-] Peer disconnected: {}", peer_names.short(&pid));
|
||||
|
||||
// Notify web UI
|
||||
{
|
||||
let mut ws = web_state_events.write().await;
|
||||
ws.peers.retain(|p| p != &pid);
|
||||
let msg = serde_json::json!({
|
||||
"type": "peer_disconnected",
|
||||
"peer_id": pid,
|
||||
});
|
||||
web::broadcast_to_clients(&ws.ws_clients, &msg.to_string()).await;
|
||||
}
|
||||
}
|
||||
NetworkEvent::DirectMessage { from, message } => {
|
||||
// Try to extract the text
|
||||
let text = if let Ok(chat_msg) = serde_json::from_slice::<tiwd_chat::ChatMessage>(&message.payload) {
|
||||
match &chat_msg.content {
|
||||
tiwd_chat::protocol::ChatContent::Text(t) => t.clone(),
|
||||
_ => "<file>".to_string(),
|
||||
}
|
||||
} else {
|
||||
String::from_utf8_lossy(&message.payload).to_string()
|
||||
};
|
||||
|
||||
let from_str = from.to_string();
|
||||
let sender = peer_names.short(&from_str);
|
||||
println!();
|
||||
println!(" ┌── Message from {} ──", sender);
|
||||
println!(" │ {}", text);
|
||||
println!(" └────────────────────────────────");
|
||||
println!();
|
||||
|
||||
// Notify web UI
|
||||
{
|
||||
let ws = web_state_events.read().await;
|
||||
let msg = serde_json::json!({
|
||||
"type": "dm_received",
|
||||
"from": from_str,
|
||||
"from_name": sender,
|
||||
"text": text,
|
||||
});
|
||||
web::broadcast_to_clients(&ws.ws_clients, &msg.to_string()).await;
|
||||
}
|
||||
|
||||
// Auto-reply if enabled
|
||||
if let Some(ref reply_text) = auto_reply {
|
||||
let reply = DirectMessage {
|
||||
from: peer_id.to_string(),
|
||||
payload: reply_text.as_bytes().to_vec(),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
signature: Vec::new(),
|
||||
msg_type: "dm".to_string(),
|
||||
};
|
||||
let _ = command_tx.send(NetworkCommand::SendDirectMessage {
|
||||
peer: from,
|
||||
message: reply,
|
||||
}).await;
|
||||
println!(" [auto-reply sent to {}]", sender);
|
||||
}
|
||||
}
|
||||
NetworkEvent::GossipMessage { topic, from, data } => {
|
||||
if let Ok(chat_msg) = serde_json::from_slice::<tiwd_chat::ChatMessage>(&data) {
|
||||
let sender = from
|
||||
.map(|p| {
|
||||
let pid = p.to_string();
|
||||
peer_names.short(&pid)
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let channel = topic.split('/').last().unwrap_or(&topic);
|
||||
match &chat_msg.content {
|
||||
tiwd_chat::protocol::ChatContent::Text(text) => {
|
||||
println!(" [#{}] {}: {}", channel, sender, text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
NetworkEvent::DirectMessageResponse { from, response } => {
|
||||
// Check if this is a content response for a pending browse request
|
||||
let from_str = from.to_string();
|
||||
let mut ws = web_state_events.write().await;
|
||||
// Find the pending request that matches this peer
|
||||
let mut matched_key = None;
|
||||
for key in ws.pending_content.keys() {
|
||||
// Match by checking if this peer is the one we sent to
|
||||
matched_key = Some(key.clone());
|
||||
break;
|
||||
}
|
||||
if let Some(key) = matched_key {
|
||||
if let Some(tx) = ws.pending_content.remove(&key) {
|
||||
let _ = tx.send(web::ContentResult {
|
||||
status: response.status,
|
||||
content_type: response.content_type.clone(),
|
||||
body: response.payload.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
NetworkEvent::DhtValueFound { key: _, value } => {
|
||||
if let Ok(record) = NameRecord::from_bytes(&value) {
|
||||
println!();
|
||||
println!(" ╔══════════════════════════════════════╗");
|
||||
println!(" ║ Name Resolved ║");
|
||||
println!(" ║ {}.tiwd → {}... ║",
|
||||
format!("{:<10}", record.name),
|
||||
&record.peer_id[..16]);
|
||||
println!(" ╚══════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
// Track the name for display
|
||||
peer_names.set(&record.peer_id, &format!("{}.tiwd", record.name));
|
||||
|
||||
// Update web state name cache for browse requests
|
||||
{
|
||||
let mut ws = web_state_events.write().await;
|
||||
ws.name_cache.insert(record.name.clone(), record.peer_id.clone());
|
||||
}
|
||||
|
||||
// Check if there's a pending message for this name
|
||||
let name = record.name.clone();
|
||||
name_cache.insert(record.clone());
|
||||
|
||||
if let Some(msg_text) = pending_msgs.remove(&name) {
|
||||
if let Ok(target_peer) = record.peer_id.parse::<libp2p::PeerId>() {
|
||||
let dm = DirectMessage {
|
||||
from: peer_id.to_string(),
|
||||
payload: msg_text.as_bytes().to_vec(),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
signature: Vec::new(),
|
||||
msg_type: "dm".to_string(),
|
||||
};
|
||||
let _ = command_tx.send(NetworkCommand::SendDirectMessage {
|
||||
peer: target_peer,
|
||||
message: dm,
|
||||
}).await;
|
||||
println!(" [sent to {}.tiwd]: {}", name, msg_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(line) = stdin_rx.recv() => {
|
||||
let line = line.trim().to_string();
|
||||
if line.is_empty() { continue; }
|
||||
|
||||
if line.starts_with('/') {
|
||||
let parts: Vec<&str> = line.splitn(3, ' ').collect();
|
||||
match parts[0] {
|
||||
"/quit" | "/exit" | "/q" => {
|
||||
println!(" Goodbye.");
|
||||
break;
|
||||
}
|
||||
"/peers" => {
|
||||
if connected_peers.is_empty() {
|
||||
println!(" No peers connected.");
|
||||
} else {
|
||||
println!(" Connected peers ({}):", connected_peers.len());
|
||||
for p in &connected_peers {
|
||||
println!(" {} ", peer_names.display(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
"/trust" => {
|
||||
println!(" ┌── Trust Score ──────────────────────┐");
|
||||
println!(" │ Total: {:.1}/100 │", trust.total());
|
||||
println!(" │ Time: {:.1}/40 │", trust.time_score);
|
||||
println!(" │ Vouches: {:.1}/35 │", trust.vouch_score);
|
||||
println!(" │ Activity: {:.1}/25 │", trust.activity_score);
|
||||
println!(" │ Role: {:?}{} │", trust.role(),
|
||||
" ".repeat(22 - format!("{:?}", trust.role()).len()));
|
||||
println!(" └──────────────────────────────────────┘");
|
||||
}
|
||||
"/msg" => {
|
||||
if parts.len() < 3 {
|
||||
println!(" Usage: /msg <name> <message>");
|
||||
println!(" Example: /msg alice Hello!");
|
||||
} else {
|
||||
let name = parts[1].to_lowercase().replace(".tiwd", "");
|
||||
let msg_text = parts[2].to_string();
|
||||
|
||||
// Check cache first
|
||||
if let Some(record) = name_cache.get(&name) {
|
||||
if let Ok(target) = record.peer_id.parse::<libp2p::PeerId>() {
|
||||
let dm = DirectMessage {
|
||||
from: peer_id.to_string(),
|
||||
payload: msg_text.as_bytes().to_vec(),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
signature: Vec::new(),
|
||||
msg_type: "dm".to_string(),
|
||||
};
|
||||
let _ = command_tx.send(NetworkCommand::SendDirectMessage {
|
||||
peer: target,
|
||||
message: dm,
|
||||
}).await;
|
||||
println!(" [sent to {}.tiwd]: {}", name, msg_text);
|
||||
}
|
||||
} else {
|
||||
// Resolve name first, then send
|
||||
println!(" Resolving {}.tiwd...", name);
|
||||
pending_msgs.insert(name.clone(), msg_text);
|
||||
let key = NameRecord::dht_key(&name);
|
||||
let _ = command_tx.send(NetworkCommand::DhtGet { key }).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
"/dm" => {
|
||||
if parts.len() < 3 {
|
||||
println!(" Usage: /dm <peer_id> <message>");
|
||||
} else if let Ok(peer) = parts[1].parse::<libp2p::PeerId>() {
|
||||
let dm = DirectMessage {
|
||||
from: peer_id.to_string(),
|
||||
payload: parts[2].as_bytes().to_vec(),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
signature: Vec::new(),
|
||||
msg_type: "dm".to_string(),
|
||||
};
|
||||
let _ = command_tx.send(NetworkCommand::SendDirectMessage {
|
||||
peer,
|
||||
message: dm,
|
||||
}).await;
|
||||
println!(" [sent]: {}", parts[2]);
|
||||
} else {
|
||||
println!(" Invalid peer ID. Use /msg <name> instead.");
|
||||
}
|
||||
}
|
||||
"/register" => {
|
||||
if parts.len() < 2 {
|
||||
println!(" Usage: /register <name>");
|
||||
} else {
|
||||
let name = parts[1].to_lowercase();
|
||||
if !NameRecord::is_valid_name(&name) {
|
||||
println!(" Invalid name. Use 3-32 chars, alphanumeric and hyphens.");
|
||||
} else {
|
||||
let record = NameRecord::new(&name, peer_id);
|
||||
let key = NameRecord::dht_key(&name);
|
||||
if let Ok(value) = record.to_bytes() {
|
||||
let _ = command_tx.send(NetworkCommand::DhtPut { key, value }).await;
|
||||
name_cache.add_my_name(name.clone());
|
||||
name_cache.insert(record);
|
||||
println!();
|
||||
println!(" ╔══════════════════════════════════════╗");
|
||||
println!(" ║ Name Registered! ║");
|
||||
println!(" ║ {}.tiwd is now yours.{} ║", name,
|
||||
" ".repeat(16 - name.len().min(16)));
|
||||
println!(" ╚══════════════════════════════════════╝");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"/resolve" | "/lookup" | "/find" => {
|
||||
if parts.len() < 2 {
|
||||
println!(" Usage: /resolve <name>");
|
||||
} else {
|
||||
let name = parts[1].to_lowercase().replace(".tiwd", "");
|
||||
if let Some(record) = name_cache.get(&name) {
|
||||
println!(" {}.tiwd → {}", name, &record.peer_id[..20]);
|
||||
} else {
|
||||
println!(" Resolving {}.tiwd...", name);
|
||||
let key = NameRecord::dht_key(&name);
|
||||
let _ = command_tx.send(NetworkCommand::DhtGet { key }).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
"/names" | "/mynames" => {
|
||||
let names = name_cache.my_names();
|
||||
if names.is_empty() {
|
||||
println!(" No names registered. Use /register <name>");
|
||||
} else {
|
||||
println!(" Your TIWD names:");
|
||||
for name in names {
|
||||
println!(" {}.tiwd", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
"/help" | "/?" => {
|
||||
println!(" Commands:");
|
||||
println!(" /msg <name> <text> Send message by TIWD name");
|
||||
println!(" /dm <peerid> <text> Send message by Peer ID");
|
||||
println!(" /register <name> Register a TIWD name");
|
||||
println!(" /resolve <name> Look up a TIWD name");
|
||||
println!(" /names Your registered names");
|
||||
println!(" /peers Connected peers");
|
||||
println!(" /trust Your trust score");
|
||||
println!(" /quit Exit");
|
||||
}
|
||||
_ => {
|
||||
println!(" Unknown command. Type /help for commands.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Send to #global
|
||||
println!(" (Global chat coming soon. Use /msg <name> <text> for DMs)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
network_handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
302
crates/tiwd-node/src/proxy.rs
Normal file
302
crates/tiwd-node/src/proxy.rs
Normal file
@ -0,0 +1,302 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::web::{ContentResult, SharedState};
|
||||
use tiwd_core::names::NameRecord;
|
||||
use tiwd_core::network::{DirectMessage, NetworkCommand};
|
||||
|
||||
/// Start a local HTTP proxy that intercepts .tiwd domains.
|
||||
/// Only .tiwd requests go through the P2P network.
|
||||
/// All other requests are forwarded to the real internet.
|
||||
pub async fn start_proxy(state: SharedState, proxy_port: u16) {
|
||||
let listener = match TcpListener::bind(format!("127.0.0.1:{}", proxy_port)).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to start proxy on port {}: {}", proxy_port, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!(" TIWD Proxy: localhost:{}", proxy_port);
|
||||
println!();
|
||||
|
||||
loop {
|
||||
if let Ok((stream, _)) = listener.accept().await {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, state).await {
|
||||
tracing::debug!("Proxy connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
state: SharedState,
|
||||
) -> anyhow::Result<()> {
|
||||
// Read the HTTP request
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = stream.read(&mut buf).await?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
|
||||
// Parse the first line: "GET http://server.tiwd/ HTTP/1.1"
|
||||
let first_line = request.lines().next().unwrap_or("");
|
||||
let parts: Vec<&str> = first_line.split_whitespace().collect();
|
||||
if parts.len() < 3 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let method = parts[0];
|
||||
let url = parts[1];
|
||||
|
||||
// Handle CONNECT method (HTTPS proxy — not supported for .tiwd)
|
||||
if method == "CONNECT" {
|
||||
if url.contains(".tiwd") {
|
||||
let response = "HTTP/1.1 501 Not Implemented\r\n\r\nTIWD proxy does not support HTTPS for .tiwd domains. Use http:// instead.\r\n";
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
} else {
|
||||
// For non-.tiwd CONNECT, tunnel through
|
||||
let _ = forward_connect(&mut stream, url).await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Extract host from URL or Host header
|
||||
let host = if url.starts_with("http://") {
|
||||
url.trim_start_matches("http://")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
} else {
|
||||
// Try Host header
|
||||
request
|
||||
.lines()
|
||||
.find(|l| l.to_lowercase().starts_with("host:"))
|
||||
.map(|l| l.split(':').skip(1).collect::<Vec<_>>().join(":").trim().to_string())
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
|
||||
if host.ends_with(".tiwd") {
|
||||
// This is a TIWD request — handle it P2P
|
||||
let name = host.replace(".tiwd", "").to_lowercase();
|
||||
let path = if url.starts_with("http://") {
|
||||
let after_host = url
|
||||
.trim_start_matches("http://")
|
||||
.find('/')
|
||||
.map(|i| &url.trim_start_matches("http://")[i..])
|
||||
.unwrap_or("/");
|
||||
after_host.to_string()
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
|
||||
let file_path = if path == "/" || path.is_empty() {
|
||||
"index.html".to_string()
|
||||
} else {
|
||||
path.trim_start_matches('/').to_string()
|
||||
};
|
||||
|
||||
let response = fetch_tiwd_content(&name, &file_path, &state).await;
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
} else {
|
||||
// Not a .tiwd domain — forward to the real internet
|
||||
let _ = forward_http(&mut stream, &request, &host, url).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch content from a TIWD peer
|
||||
async fn fetch_tiwd_content(name: &str, file_path: &str, state: &SharedState) -> String {
|
||||
// Step 1: Resolve the name
|
||||
let peer_id_str = {
|
||||
let s = state.read().await;
|
||||
s.name_cache.get(name).cloned()
|
||||
};
|
||||
|
||||
let peer_id_str = match peer_id_str {
|
||||
Some(pid) => pid,
|
||||
None => {
|
||||
// Resolve via DHT
|
||||
{
|
||||
let s = state.read().await;
|
||||
let key = NameRecord::dht_key(name);
|
||||
let _ = s.command_tx.send(NetworkCommand::DhtGet { key }).await;
|
||||
}
|
||||
|
||||
let mut resolved = None;
|
||||
for _ in 0..50 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let s = state.read().await;
|
||||
if let Some(pid) = s.name_cache.get(name) {
|
||||
resolved = Some(pid.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match resolved {
|
||||
Some(pid) => pid,
|
||||
None => {
|
||||
let body = format!(
|
||||
"<html><body style='font-family:system-ui;background:#0d1117;color:#e6edf3;display:flex;justify-content:center;align-items:center;height:100vh;margin:0'><div style='text-align:center'><h1 style='color:#f85149'>{}.tiwd</h1><p style='color:#7d8590'>Could not resolve this name.</p></div></body></html>",
|
||||
name
|
||||
);
|
||||
return format!(
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Send content request
|
||||
let peer: libp2p::PeerId = match peer_id_str.parse() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
let body = "<h1>Error: Invalid PeerId</h1>";
|
||||
return format!(
|
||||
"HTTP/1.1 500 Error\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel::<ContentResult>();
|
||||
let request_id = format!("{}:{}", name, file_path);
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.pending_content.insert(request_id.clone(), response_tx);
|
||||
|
||||
let dm = DirectMessage {
|
||||
from: s.peer_id.clone(),
|
||||
payload: file_path.as_bytes().to_vec(),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
signature: Vec::new(),
|
||||
msg_type: "content_request".to_string(),
|
||||
};
|
||||
let _ = s
|
||||
.command_tx
|
||||
.send(NetworkCommand::SendDirectMessage {
|
||||
peer,
|
||||
message: dm,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// Step 3: Wait for response
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), response_rx).await {
|
||||
Ok(Ok(result)) => {
|
||||
let status = if result.status == 200 { "200 OK" } else { "404 Not Found" };
|
||||
format!(
|
||||
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
status,
|
||||
result.content_type,
|
||||
result.body.len(),
|
||||
String::from_utf8_lossy(&result.body)
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let mut s = state.write().await;
|
||||
s.pending_content.remove(&request_id);
|
||||
let body = format!(
|
||||
"<html><body style='font-family:system-ui;background:#0d1117;color:#e6edf3;display:flex;justify-content:center;align-items:center;height:100vh;margin:0'><div style='text-align:center'><h1 style='color:#e3b341'>{}.tiwd</h1><p style='color:#7d8590'>Request timed out.</p></div></body></html>",
|
||||
name
|
||||
);
|
||||
format!(
|
||||
"HTTP/1.1 504 Gateway Timeout\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a regular HTTP request to the real internet
|
||||
async fn forward_http(
|
||||
client: &mut tokio::net::TcpStream,
|
||||
request: &str,
|
||||
host: &str,
|
||||
_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
// Connect to the real server
|
||||
let addr = format!("{}:80", host);
|
||||
match tokio::net::TcpStream::connect(&addr).await {
|
||||
Ok(mut server) => {
|
||||
// Forward the request (rewrite absolute URL to relative)
|
||||
let rewritten = request.replacen(
|
||||
&format!("http://{}", host),
|
||||
"",
|
||||
1,
|
||||
);
|
||||
server.write_all(rewritten.as_bytes()).await?;
|
||||
|
||||
// Relay response back
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
match server.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
client.write_all(&buf[..n]).await?;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let body = format!("<h1>502 Bad Gateway</h1><p>Could not connect to {}</p>", host);
|
||||
let response = format!(
|
||||
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
);
|
||||
client.write_all(response.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle CONNECT (HTTPS tunneling) for non-.tiwd domains
|
||||
async fn forward_connect(
|
||||
client: &mut tokio::net::TcpStream,
|
||||
target: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
// Connect to the real server
|
||||
match tokio::net::TcpStream::connect(target).await {
|
||||
Ok(mut server) => {
|
||||
// Tell client the tunnel is established
|
||||
client
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await?;
|
||||
|
||||
// Bidirectional relay
|
||||
let (mut client_read, mut client_write) = tokio::io::split(client);
|
||||
let (mut server_read, mut server_write) = tokio::io::split(&mut server);
|
||||
|
||||
let c2s = tokio::io::copy(&mut client_read, &mut server_write);
|
||||
let s2c = tokio::io::copy(&mut server_read, &mut client_write);
|
||||
|
||||
tokio::select! {
|
||||
_ = c2s => {}
|
||||
_ = s2c => {}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
client
|
||||
.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
119
crates/tiwd-node/src/site.rs
Normal file
119
crates/tiwd-node/src/site.rs
Normal file
@ -0,0 +1,119 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use tiwd_core::content::{ContentRequest, ContentResponse};
|
||||
use tracing::info;
|
||||
|
||||
/// Manages the local site files that this node serves to the network.
|
||||
/// When another node requests a page from your TIWD name, this module
|
||||
/// serves the files from your local site directory.
|
||||
pub struct SiteManager {
|
||||
/// Directory containing the site files.
|
||||
site_dir: PathBuf,
|
||||
/// Whether this node is hosting a site.
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl SiteManager {
|
||||
pub fn new(site_dir: &Path) -> Self {
|
||||
let enabled = site_dir.exists() && site_dir.is_dir();
|
||||
if enabled {
|
||||
info!("Site hosting enabled from {:?}", site_dir);
|
||||
}
|
||||
Self {
|
||||
site_dir: site_dir.to_path_buf(),
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a new site with a default index.html.
|
||||
pub fn init_site(site_dir: &Path, site_name: &str) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(site_dir)?;
|
||||
let index_path = site_dir.join("index.html");
|
||||
if !index_path.exists() {
|
||||
let html = format!(r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{name}.tiwd</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
}}
|
||||
.container {{ max-width: 600px; padding: 40px; }}
|
||||
h1 {{ font-size: 48px; margin-bottom: 16px; }}
|
||||
h1 span {{ color: #00d4aa; }}
|
||||
p {{ color: #7d8590; font-size: 18px; line-height: 1.6; margin-bottom: 24px; }}
|
||||
.badge {{
|
||||
display: inline-block;
|
||||
background: #1c2333;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
color: #00d4aa;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><span>{name}</span>.tiwd</h1>
|
||||
<p>This page is served entirely peer-to-peer on the TIWD network. No servers. No DNS. No HTTP. Just cryptographic identity and a decentralized hash table.</p>
|
||||
<div class="badge">Hosted on The Internet We Deserve</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#, name = site_name);
|
||||
std::fs::write(&index_path, html)?;
|
||||
info!("Created default site at {:?}", index_path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a content request from another node.
|
||||
pub fn handle_request(&self, request: &ContentRequest) -> ContentResponse {
|
||||
if !self.enabled {
|
||||
return ContentResponse::not_found();
|
||||
}
|
||||
|
||||
// Normalize the path
|
||||
let mut path = request.path.trim_start_matches('/').to_string();
|
||||
if path.is_empty() {
|
||||
path = "index.html".to_string();
|
||||
}
|
||||
|
||||
// Security: prevent directory traversal
|
||||
if path.contains("..") || path.contains('\\') {
|
||||
return ContentResponse::not_found();
|
||||
}
|
||||
|
||||
let file_path = self.site_dir.join(&path);
|
||||
|
||||
// Try to read the file
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(body) => {
|
||||
let mime = ContentResponse::mime_for_path(&path);
|
||||
info!("Serving {} ({}, {} bytes)", path, mime, body.len());
|
||||
ContentResponse::ok(body, mime)
|
||||
}
|
||||
Err(_) => {
|
||||
// Try with .html extension
|
||||
let html_path = self.site_dir.join(format!("{}.html", path));
|
||||
match std::fs::read(&html_path) {
|
||||
Ok(body) => ContentResponse::ok(body, "text/html"),
|
||||
Err(_) => ContentResponse::not_found(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
482
crates/tiwd-node/src/web.rs
Normal file
482
crates/tiwd-node/src/web.rs
Normal file
@ -0,0 +1,482 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tiwd_core::names::NameRecord;
|
||||
use tiwd_core::network::{DirectMessage, NetworkCommand};
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use warp::ws::{Message, WebSocket};
|
||||
use warp::Filter;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
|
||||
/// Shared state between the web server and the P2P network.
|
||||
pub struct WebState {
|
||||
pub command_tx: mpsc::Sender<NetworkCommand>,
|
||||
pub peer_id: String,
|
||||
pub name: Option<String>,
|
||||
pub peers: Vec<String>,
|
||||
pub ws_clients: Vec<mpsc::Sender<String>>,
|
||||
/// Pending content requests — waiting for P2P response
|
||||
/// Maps a request ID to a oneshot sender for the response
|
||||
pub pending_content: HashMap<String, tokio::sync::oneshot::Sender<ContentResult>>,
|
||||
/// Resolved names cache (name → PeerId string)
|
||||
pub name_cache: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Result of a content fetch from a peer
|
||||
pub struct ContentResult {
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
pub type SharedState = Arc<RwLock<WebState>>;
|
||||
|
||||
/// Start the web server on the given port.
|
||||
pub async fn start_web_server(state: SharedState, web_port: u16) {
|
||||
let state_ws = state.clone();
|
||||
let state_static = state.clone();
|
||||
|
||||
// WebSocket endpoint
|
||||
let ws_route = warp::path("ws")
|
||||
.and(warp::ws())
|
||||
.and(warp::any().map(move || state_ws.clone()))
|
||||
.map(|ws: warp::ws::Ws, state: SharedState| {
|
||||
ws.on_upgrade(move |socket| handle_ws_client(socket, state))
|
||||
});
|
||||
|
||||
// Browse TIWD sites — /browse/name.tiwd/path
|
||||
let state_browse = state.clone();
|
||||
let browse_route = warp::path("browse")
|
||||
.and(warp::path::tail())
|
||||
.and(warp::any().map(move || state_browse.clone()))
|
||||
.and_then(handle_browse);
|
||||
|
||||
// Local site preview — /site/path (view your own hosted site)
|
||||
let state_site = state.clone();
|
||||
let site_route = warp::path("site")
|
||||
.and(warp::path::tail())
|
||||
.and(warp::any().map(move || state_site.clone()))
|
||||
.and_then(handle_local_site);
|
||||
|
||||
// PAC file — tells the browser to use TIWD proxy only for .tiwd domains
|
||||
let proxy_port_for_pac = std::env::var("TIWD_PROXY_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse::<u16>().ok())
|
||||
.unwrap_or(9080);
|
||||
let pac_route = warp::path("proxy.pac")
|
||||
.map(move || {
|
||||
let pac = format!(
|
||||
r#"function FindProxyForURL(url, host) {{
|
||||
if (shExpMatch(host, "*.tiwd") || dnsDomainIs(host, ".tiwd")) {{
|
||||
return "PROXY localhost:{}";
|
||||
}}
|
||||
return "DIRECT";
|
||||
}}"#,
|
||||
proxy_port_for_pac
|
||||
);
|
||||
warp::http::Response::builder()
|
||||
.header("Content-Type", "application/x-ns-proxy-autoconfig")
|
||||
.body(pac)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
// Chat UI — served at /chat
|
||||
let chat_html = warp::path("chat")
|
||||
.and(warp::path::end())
|
||||
.map(|| {
|
||||
warp::reply::html(include_str!("../static/index.html"))
|
||||
});
|
||||
|
||||
// Homepage — serve site index.html if it exists, otherwise chat UI
|
||||
let state_home = state.clone();
|
||||
let static_html = warp::path::end()
|
||||
.and(warp::any().map(move || state_home.clone()))
|
||||
.map(|_state: SharedState| {
|
||||
let site_dir = std::env::var("TIWD_SITE_DIR").unwrap_or_else(|_| {
|
||||
format!("{}/site", std::env::var("TIWD_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
|
||||
});
|
||||
let index_path = std::path::PathBuf::from(&site_dir).join("index.html");
|
||||
if index_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&index_path) {
|
||||
return warp::reply::html(content);
|
||||
}
|
||||
}
|
||||
warp::reply::html(include_str!("../static/index.html").to_string())
|
||||
});
|
||||
|
||||
// Serve static assets (images, etc) from site directory
|
||||
let site_dir_env = std::env::var("TIWD_SITE_DIR").unwrap_or_else(|_| {
|
||||
format!("{}/site", std::env::var("TIWD_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
|
||||
});
|
||||
let static_files = warp::path("static")
|
||||
.and(warp::fs::dir(site_dir_env.clone()));
|
||||
|
||||
// Serve images at root level (for website compatibility)
|
||||
let root_files = warp::fs::dir(site_dir_env);
|
||||
|
||||
let routes = ws_route
|
||||
.or(pac_route)
|
||||
.or(chat_html)
|
||||
.or(browse_route)
|
||||
.or(site_route)
|
||||
.or(static_html)
|
||||
.or(static_files)
|
||||
.or(root_files);
|
||||
|
||||
// Check for TLS certificates
|
||||
let tls_cert = std::env::var("TIWD_TLS_CERT").ok();
|
||||
let tls_key = std::env::var("TIWD_TLS_KEY").ok();
|
||||
|
||||
match (tls_cert, tls_key) {
|
||||
(Some(cert_path), Some(key_path)) => {
|
||||
println!(" HTTPS: https://0.0.0.0:{}", web_port);
|
||||
println!(" Your site: https://localhost:{}/site/", web_port);
|
||||
println!(" Browse TIWD: https://localhost:{}/browse/name.tiwd", web_port);
|
||||
println!();
|
||||
|
||||
// Serve with TLS — no Nginx needed
|
||||
warp::serve(routes)
|
||||
.tls()
|
||||
.cert_path(&cert_path)
|
||||
.key_path(&key_path)
|
||||
.run(([0, 0, 0, 0], web_port))
|
||||
.await;
|
||||
}
|
||||
_ => {
|
||||
// No TLS — serve HTTP (local development or behind a proxy)
|
||||
let bind_addr = if std::env::var("TIWD_PUBLIC").is_ok() {
|
||||
[0, 0, 0, 0]
|
||||
} else {
|
||||
[127, 0, 0, 1]
|
||||
};
|
||||
println!(" Chat UI: http://localhost:{}", web_port);
|
||||
println!(" Your site: http://localhost:{}/site/", web_port);
|
||||
println!(" Browse TIWD: http://localhost:{}/browse/name.tiwd", web_port);
|
||||
println!();
|
||||
|
||||
warp::serve(routes).run((bind_addr, web_port)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a WebSocket client connection.
|
||||
async fn handle_ws_client(ws: WebSocket, state: SharedState) {
|
||||
let (mut ws_tx, mut ws_rx) = ws.split();
|
||||
let (client_tx, mut client_rx) = mpsc::channel::<String>(64);
|
||||
|
||||
// Register this client
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.ws_clients.push(client_tx.clone());
|
||||
|
||||
// Send identity info
|
||||
let identity = serde_json::json!({
|
||||
"type": "identity",
|
||||
"peer_id": s.peer_id,
|
||||
"name": s.name,
|
||||
});
|
||||
let _ = ws_tx.send(Message::text(identity.to_string())).await;
|
||||
|
||||
// Send existing peers
|
||||
for peer in &s.peers {
|
||||
let msg = serde_json::json!({
|
||||
"type": "peer_discovered",
|
||||
"peer_id": peer,
|
||||
"display": &peer[..12.min(peer.len())],
|
||||
});
|
||||
let _ = ws_tx.send(Message::text(msg.to_string())).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn task to forward messages from P2P to WebSocket
|
||||
let forward_task = tokio::spawn(async move {
|
||||
while let Some(msg) = client_rx.recv().await {
|
||||
if ws_tx.send(Message::text(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle incoming WebSocket messages (from the UI)
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
if let Ok(text) = msg.to_str() {
|
||||
if let Ok(action) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
handle_ws_action(action, &state).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client disconnected — remove from list
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.ws_clients.retain(|tx| !tx.is_closed());
|
||||
}
|
||||
|
||||
forward_task.abort();
|
||||
}
|
||||
|
||||
/// Handle an action from the web UI.
|
||||
async fn handle_ws_action(action: serde_json::Value, state: &SharedState) {
|
||||
let action_type = action["action"].as_str().unwrap_or("");
|
||||
|
||||
match action_type {
|
||||
"dm" => {
|
||||
if let (Some(peer_id_str), Some(text)) = (
|
||||
action["peer_id"].as_str(),
|
||||
action["text"].as_str(),
|
||||
) {
|
||||
if let Ok(peer) = peer_id_str.parse::<libp2p::PeerId>() {
|
||||
let s = state.read().await;
|
||||
let dm = DirectMessage {
|
||||
from: s.peer_id.clone(),
|
||||
payload: text.as_bytes().to_vec(),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
signature: Vec::new(),
|
||||
msg_type: "dm".to_string(),
|
||||
};
|
||||
let _ = s.command_tx.send(NetworkCommand::SendDirectMessage {
|
||||
peer,
|
||||
message: dm,
|
||||
}).await;
|
||||
|
||||
// Confirm to UI
|
||||
let confirm = serde_json::json!({
|
||||
"type": "dm_sent",
|
||||
"to": peer_id_str,
|
||||
"text": text,
|
||||
});
|
||||
broadcast_to_clients(&s.ws_clients, &confirm.to_string()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
"msg" => {
|
||||
if let (Some(name), Some(text)) = (
|
||||
action["name"].as_str(),
|
||||
action["text"].as_str(),
|
||||
) {
|
||||
let name = name.to_lowercase().replace(".tiwd", "");
|
||||
let s = state.read().await;
|
||||
// Resolve name via DHT
|
||||
let key = NameRecord::dht_key(&name);
|
||||
let _ = s.command_tx.send(NetworkCommand::DhtGet { key }).await;
|
||||
// TODO: queue the message to send after resolution
|
||||
}
|
||||
}
|
||||
"register" => {
|
||||
if let Some(name) = action["name"].as_str() {
|
||||
let name = name.to_lowercase();
|
||||
if NameRecord::is_valid_name(&name) {
|
||||
let mut s = state.write().await;
|
||||
let record = NameRecord::new(&name, s.peer_id.parse().unwrap_or(
|
||||
libp2p::PeerId::random()
|
||||
));
|
||||
let key = NameRecord::dht_key(&name);
|
||||
if let Ok(value) = record.to_bytes() {
|
||||
let _ = s.command_tx.send(NetworkCommand::DhtPut { key, value }).await;
|
||||
s.name = Some(name.clone());
|
||||
|
||||
let confirm = serde_json::json!({
|
||||
"type": "name_registered",
|
||||
"name": name,
|
||||
});
|
||||
broadcast_to_clients(&s.ws_clients, &confirm.to_string()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"resolve" => {
|
||||
if let Some(name) = action["name"].as_str() {
|
||||
let name = name.to_lowercase().replace(".tiwd", "");
|
||||
let s = state.read().await;
|
||||
let key = NameRecord::dht_key(&name);
|
||||
let _ = s.command_tx.send(NetworkCommand::DhtGet { key }).await;
|
||||
}
|
||||
}
|
||||
"chat" => {
|
||||
// Global chat — for now just echo back
|
||||
// TODO: publish via GossipSub
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message to all connected WebSocket clients.
|
||||
pub async fn broadcast_to_clients(clients: &[mpsc::Sender<String>], msg: &str) {
|
||||
for tx in clients {
|
||||
let _ = tx.send(msg.to_string()).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify all web UI clients about a network event.
|
||||
pub async fn notify_event(state: &SharedState, event_json: &str) {
|
||||
let s = state.read().await;
|
||||
broadcast_to_clients(&s.ws_clients, event_json).await;
|
||||
}
|
||||
|
||||
/// Handle browsing a TIWD site — /browse/name.tiwd/path
|
||||
async fn handle_browse(
|
||||
tail: warp::path::Tail,
|
||||
state: SharedState,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let full_path = tail.as_str().to_string();
|
||||
|
||||
// Parse: "name.tiwd/path/to/file" or just "name.tiwd"
|
||||
let parts: Vec<&str> = full_path.splitn(2, '/').collect();
|
||||
let name = parts[0].replace(".tiwd", "").to_lowercase();
|
||||
let path = if parts.len() > 1 && !parts[1].is_empty() {
|
||||
parts[1].to_string()
|
||||
} else {
|
||||
"index.html".to_string()
|
||||
};
|
||||
|
||||
// Step 1: Check if we already have the PeerId cached
|
||||
let peer_id_str = {
|
||||
let s = state.read().await;
|
||||
s.name_cache.get(&name).cloned()
|
||||
};
|
||||
|
||||
let peer_id_str = match peer_id_str {
|
||||
Some(pid) => pid,
|
||||
None => {
|
||||
// Resolve via DHT — trigger the lookup
|
||||
{
|
||||
let s = state.read().await;
|
||||
let key = NameRecord::dht_key(&name);
|
||||
let _ = s.command_tx.send(NetworkCommand::DhtGet { key }).await;
|
||||
}
|
||||
|
||||
// Wait for resolution (poll the cache for up to 5 seconds)
|
||||
let mut resolved = None;
|
||||
for _ in 0..50 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let s = state.read().await;
|
||||
if let Some(pid) = s.name_cache.get(&name) {
|
||||
resolved = Some(pid.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match resolved {
|
||||
Some(pid) => pid,
|
||||
None => {
|
||||
return Ok(warp::reply::with_status(
|
||||
warp::reply::html(format!(
|
||||
r#"<!DOCTYPE html><html><head><title>{name}.tiwd</title>
|
||||
<style>body{{font-family:system-ui;background:#0d1117;color:#e6edf3;display:flex;justify-content:center;align-items:center;height:100vh;}}
|
||||
.c{{text-align:center;}}.c h1{{color:#f85149;}}.c p{{color:#7d8590;margin-top:12px;}}</style></head>
|
||||
<body><div class="c"><h1>{name}.tiwd</h1><p>Could not resolve this name. The peer may be offline or the name may not be registered.</p></div></body></html>"#,
|
||||
name = name
|
||||
)),
|
||||
warp::http::StatusCode::NOT_FOUND,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Send content request to the peer
|
||||
let peer: libp2p::PeerId = match peer_id_str.parse() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return Ok(warp::reply::with_status(
|
||||
warp::reply::html("<h1>Error: Invalid PeerId</h1>".to_string()),
|
||||
warp::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Create a oneshot channel to receive the content response
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel::<ContentResult>();
|
||||
let request_id = format!("{}:{}", name, path);
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.pending_content.insert(request_id.clone(), response_tx);
|
||||
|
||||
// Send content request as a DirectMessage with type "content_request"
|
||||
let dm = DirectMessage {
|
||||
from: s.peer_id.clone(),
|
||||
payload: path.as_bytes().to_vec(),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
signature: Vec::new(),
|
||||
msg_type: "content_request".to_string(),
|
||||
};
|
||||
let _ = s.command_tx.send(NetworkCommand::SendDirectMessage {
|
||||
peer,
|
||||
message: dm,
|
||||
}).await;
|
||||
}
|
||||
|
||||
// Step 3: Wait for the response (up to 10 seconds)
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
response_rx,
|
||||
).await {
|
||||
Ok(Ok(result)) => {
|
||||
Ok(warp::reply::with_status(
|
||||
warp::reply::html(String::from_utf8_lossy(&result.body).to_string()),
|
||||
if result.status == 200 {
|
||||
warp::http::StatusCode::OK
|
||||
} else {
|
||||
warp::http::StatusCode::NOT_FOUND
|
||||
},
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
// Timeout or channel error
|
||||
let mut s = state.write().await;
|
||||
s.pending_content.remove(&request_id);
|
||||
Ok(warp::reply::with_status(
|
||||
warp::reply::html(format!(
|
||||
r#"<!DOCTYPE html><html><head><title>{name}.tiwd</title>
|
||||
<style>body{{font-family:system-ui;background:#0d1117;color:#e6edf3;display:flex;justify-content:center;align-items:center;height:100vh;}}
|
||||
.c{{text-align:center;}}.c h1{{color:#e3b341;}}.c p{{color:#7d8590;margin-top:12px;}}</style></head>
|
||||
<body><div class="c"><h1>{name}.tiwd</h1><p>Request timed out. The peer may be slow or unreachable.</p></div></body></html>"#,
|
||||
name = name
|
||||
)),
|
||||
warp::http::StatusCode::GATEWAY_TIMEOUT,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle viewing your own local site — /site/path
|
||||
async fn handle_local_site(
|
||||
tail: warp::path::Tail,
|
||||
state: SharedState,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let path = tail.as_str();
|
||||
let path = if path.is_empty() { "index.html" } else { path };
|
||||
|
||||
// Security: prevent directory traversal
|
||||
if path.contains("..") {
|
||||
return Ok(warp::reply::with_status(
|
||||
warp::reply::html("<h1>403 Forbidden</h1>".to_string()),
|
||||
warp::http::StatusCode::FORBIDDEN,
|
||||
));
|
||||
}
|
||||
|
||||
let s = state.read().await;
|
||||
let site_dir = std::path::PathBuf::from(
|
||||
std::env::var("TIWD_SITE_DIR").unwrap_or_else(|_| {
|
||||
format!("{}/site", std::env::var("TIWD_DATA_DIR").unwrap_or_else(|_| ".".to_string()))
|
||||
})
|
||||
);
|
||||
|
||||
let file_path = site_dir.join(path);
|
||||
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(body) => {
|
||||
let mime = tiwd_core::content::ContentResponse::mime_for_path(path);
|
||||
Ok(warp::reply::with_status(
|
||||
warp::reply::html(String::from_utf8_lossy(&body).to_string()),
|
||||
warp::http::StatusCode::OK,
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
Ok(warp::reply::with_status(
|
||||
warp::reply::html("<h1>404 - Page not found</h1><p>No site files found. Create files in your site directory.</p>".to_string()),
|
||||
warp::http::StatusCode::NOT_FOUND,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
717
crates/tiwd-node/static/index.html
Normal file
717
crates/tiwd-node/static/index.html
Normal file
@ -0,0 +1,717 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TIWD Chat</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-input: #0d1117;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-dim: #7d8590;
|
||||
--accent: #00d4aa;
|
||||
--accent-dim: #00a88a;
|
||||
--msg-self: #1a2a3a;
|
||||
--msg-other: #1c1c2e;
|
||||
--danger: #f85149;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.offline { background: var(--danger); animation: none; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.peer-count {
|
||||
background: var(--bg);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.trust-badge {
|
||||
background: var(--bg);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Main Layout ── */
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.identity-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.identity-name {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.identity-peer {
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.peer-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.peer-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.peer-item.active {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.peer-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.peer-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Chat Area ── */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header span {
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.message.self {
|
||||
align-self: flex-end;
|
||||
background: var(--msg-self);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.other {
|
||||
align-self: flex-start;
|
||||
background: var(--msg-other);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message.system {
|
||||
align-self: center;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.message .sender {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.message .time {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Input ── */
|
||||
.input-area {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-row input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-row input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ── Name Registration Modal ── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 100;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal input:focus { border-color: var(--accent); }
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-btn.primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tiwd-suffix {
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Log panel ── */
|
||||
.log-panel {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 16px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-entry { padding: 1px 0; }
|
||||
.log-entry.info { color: var(--accent); }
|
||||
.log-entry.warn { color: #e3b341; }
|
||||
.log-entry.error { color: var(--danger); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">TIWD</div>
|
||||
<div class="status">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="peer-count" id="peerCount">0 peers</div>
|
||||
<div class="trust-badge" id="trustBadge">Newcomer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Your Identity</div>
|
||||
<div class="identity-card">
|
||||
<div class="identity-name" id="myName">Loading...</div>
|
||||
<div class="identity-peer" id="myPeerId">...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section" style="flex: 1; overflow-y: auto;">
|
||||
<div class="sidebar-title">Peers</div>
|
||||
<ul class="peer-list" id="peerList">
|
||||
<li class="peer-item" style="color: var(--text-dim); font-style: italic; font-size: 12px;">
|
||||
Discovering peers...
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">TIWD Names</div>
|
||||
<ul class="peer-list" id="nameList">
|
||||
</ul>
|
||||
<button class="send-btn" style="width: 100%; margin-top: 8px; font-size: 12px;" onclick="showRegisterModal()">
|
||||
Register a Name
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-area">
|
||||
<div class="chat-header">
|
||||
#global <span>Encrypted peer-to-peer chat</span>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
<div class="message system">
|
||||
Welcome to TIWD — The Internet We Deserve. All messages are end-to-end encrypted.
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
<div class="input-row">
|
||||
<input type="text" id="msgInput" placeholder="Type a message..." autocomplete="off"
|
||||
onkeydown="if(event.key==='Enter')sendMessage()">
|
||||
<button class="send-btn" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-panel" id="logPanel">
|
||||
<div class="log-entry info">TIWD node starting...</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Name Modal -->
|
||||
<div class="modal-overlay" id="registerModal">
|
||||
<div class="modal">
|
||||
<h2>Register a TIWD Name</h2>
|
||||
<p>Choose a unique name for your identity on the network. This name is free, permanent, and yours.</p>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<input type="text" id="nameInput" placeholder="yourname" autocomplete="off"
|
||||
onkeydown="if(event.key==='Enter')registerName()">
|
||||
<span class="tiwd-suffix">.tiwd</span>
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-btn secondary" onclick="hideRegisterModal()">Cancel</button>
|
||||
<button class="modal-btn primary" onclick="registerName()">Register</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── State ──
|
||||
let ws = null;
|
||||
let myPeerId = '';
|
||||
let myName = '';
|
||||
let peers = [];
|
||||
let connected = false;
|
||||
|
||||
// ── WebSocket Connection ──
|
||||
function connect() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
updateStatus(true);
|
||||
log('Connected to TIWD node', 'info');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
updateStatus(false);
|
||||
log('Disconnected. Reconnecting...', 'warn');
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
handleMessage(msg);
|
||||
} catch (err) {
|
||||
log('Invalid message: ' + e.data, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'identity':
|
||||
myPeerId = msg.peer_id;
|
||||
myName = msg.name || '';
|
||||
document.getElementById('myPeerId').textContent = myPeerId;
|
||||
document.getElementById('myName').textContent = myName ? myName + '.tiwd' : myPeerId.substring(0, 16) + '...';
|
||||
break;
|
||||
|
||||
case 'peer_discovered':
|
||||
if (!peers.includes(msg.peer_id)) {
|
||||
peers.push(msg.peer_id);
|
||||
updatePeerList();
|
||||
addSystemMessage(msg.display || msg.peer_id.substring(0, 12) + ' joined the network');
|
||||
log('Peer discovered: ' + (msg.display || msg.peer_id.substring(0, 12)), 'info');
|
||||
}
|
||||
document.getElementById('peerCount').textContent = peers.length + ' peer' + (peers.length !== 1 ? 's' : '');
|
||||
break;
|
||||
|
||||
case 'peer_disconnected':
|
||||
peers = peers.filter(p => p !== msg.peer_id);
|
||||
updatePeerList();
|
||||
document.getElementById('peerCount').textContent = peers.length + ' peer' + (peers.length !== 1 ? 's' : '');
|
||||
break;
|
||||
|
||||
case 'dm_received':
|
||||
addMessage(msg.from_name || msg.from.substring(0, 12), msg.text, false);
|
||||
break;
|
||||
|
||||
case 'dm_sent':
|
||||
addMessage('You', msg.text, true);
|
||||
break;
|
||||
|
||||
case 'name_registered':
|
||||
myName = msg.name;
|
||||
document.getElementById('myName').textContent = msg.name + '.tiwd';
|
||||
addSystemMessage('Registered: ' + msg.name + '.tiwd');
|
||||
updateNameList();
|
||||
hideRegisterModal();
|
||||
break;
|
||||
|
||||
case 'name_resolved':
|
||||
addSystemMessage(msg.name + '.tiwd → ' + msg.peer_id.substring(0, 16) + '...');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
log(msg.text, 'error');
|
||||
break;
|
||||
|
||||
default:
|
||||
log('Unknown message type: ' + msg.type, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('msgInput');
|
||||
const text = input.value.trim();
|
||||
if (!text || !ws) return;
|
||||
|
||||
// Check if it's a command
|
||||
if (text.startsWith('/msg ')) {
|
||||
const parts = text.substring(5).split(' ');
|
||||
const name = parts.shift();
|
||||
const msg = parts.join(' ');
|
||||
ws.send(JSON.stringify({ action: 'msg', name: name, text: msg }));
|
||||
} else if (text.startsWith('/dm ')) {
|
||||
const parts = text.substring(4).split(' ');
|
||||
const peer = parts.shift();
|
||||
const msg = parts.join(' ');
|
||||
ws.send(JSON.stringify({ action: 'dm', peer_id: peer, text: msg }));
|
||||
} else if (text.startsWith('/resolve ')) {
|
||||
ws.send(JSON.stringify({ action: 'resolve', name: text.substring(9) }));
|
||||
} else {
|
||||
// Send as chat message
|
||||
ws.send(JSON.stringify({ action: 'chat', text: text }));
|
||||
addMessage('You', text, true);
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function registerName() {
|
||||
const input = document.getElementById('nameInput');
|
||||
const name = input.value.trim().toLowerCase().replace('.tiwd', '');
|
||||
if (!name || !ws) return;
|
||||
ws.send(JSON.stringify({ action: 'register', name: name }));
|
||||
}
|
||||
|
||||
function messagePeer(peerId) {
|
||||
const text = prompt('Message to ' + peerId.substring(0, 12) + ':');
|
||||
if (text && ws) {
|
||||
ws.send(JSON.stringify({ action: 'dm', peer_id: peerId, text: text }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI Updates ──
|
||||
function addMessage(sender, text, isSelf) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ' + (isSelf ? 'self' : 'other');
|
||||
const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
div.innerHTML = (isSelf ? '' : '<div class="sender">' + escapeHtml(sender) + '</div>') +
|
||||
escapeHtml(text) +
|
||||
'<div class="time">' + now + '</div>';
|
||||
document.getElementById('messages').appendChild(div);
|
||||
div.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function addSystemMessage(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message system';
|
||||
div.textContent = text;
|
||||
document.getElementById('messages').appendChild(div);
|
||||
div.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function updatePeerList() {
|
||||
const list = document.getElementById('peerList');
|
||||
if (peers.length === 0) {
|
||||
list.innerHTML = '<li class="peer-item" style="color: var(--text-dim); font-style: italic; font-size: 12px;">Discovering peers...</li>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = peers.map(p =>
|
||||
'<li class="peer-item" onclick="messagePeer(\'' + p + '\')">' +
|
||||
'<div class="peer-dot"></div>' +
|
||||
'<span class="peer-name">' + p.substring(0, 16) + '...</span>' +
|
||||
'</li>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function updateNameList() {
|
||||
const list = document.getElementById('nameList');
|
||||
if (myName) {
|
||||
list.innerHTML = '<li class="peer-item"><div class="peer-dot"></div><span class="peer-name">' + myName + '.tiwd</span></li>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(online) {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusText');
|
||||
dot.className = 'status-dot' + (online ? '' : ' offline');
|
||||
text.textContent = online ? 'Connected' : 'Offline';
|
||||
}
|
||||
|
||||
function showRegisterModal() {
|
||||
document.getElementById('registerModal').classList.add('active');
|
||||
document.getElementById('nameInput').focus();
|
||||
}
|
||||
|
||||
function hideRegisterModal() {
|
||||
document.getElementById('registerModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function log(text, level) {
|
||||
const panel = document.getElementById('logPanel');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry ' + (level || '');
|
||||
entry.textContent = new Date().toLocaleTimeString() + ' ' + text;
|
||||
panel.appendChild(entry);
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Start ──
|
||||
connect();
|
||||
|
||||
// Focus input on any keypress
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName !== 'INPUT' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
document.getElementById('msgInput').focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
24
crates/tiwd-sdk/Cargo.toml
Normal file
24
crates/tiwd-sdk/Cargo.toml
Normal file
@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "tiwd-sdk"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "SDK for building apps on the TIWD decentralized network"
|
||||
|
||||
[dependencies]
|
||||
tiwd-core = { path = "../tiwd-core" }
|
||||
tiwd-trust = { path = "../tiwd-trust" }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
libp2p = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "pastebin"
|
||||
path = "examples/pastebin.rs"
|
||||
108
crates/tiwd-sdk/examples/pastebin.rs
Normal file
108
crates/tiwd-sdk/examples/pastebin.rs
Normal file
@ -0,0 +1,108 @@
|
||||
/// Example: A decentralized pastebin built on TIWD.
|
||||
///
|
||||
/// This demonstrates how a third-party developer would build an app
|
||||
/// on the TIWD network using the SDK. The entire networking, encryption,
|
||||
/// peer discovery, and trust system is handled by the platform.
|
||||
///
|
||||
/// Run with:
|
||||
/// cargo run --example pastebin
|
||||
use tiwd_sdk::prelude::*;
|
||||
|
||||
// ── The App ────────────────────────────────────────────────────────
|
||||
|
||||
struct PastebinApp {
|
||||
pastes: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl PastebinApp {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
pastes: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TiwdApp for PastebinApp {
|
||||
fn manifest(&self) -> AppManifest {
|
||||
AppManifest::new("pastebin", "1.0.0")
|
||||
.with_description("Decentralized paste sharing")
|
||||
.with_gossip_topics(vec!["new-paste"])
|
||||
.with_dht_access(true)
|
||||
.with_direct_messaging(true)
|
||||
}
|
||||
|
||||
async fn on_start(&mut self, ctx: &AppContext) -> Result<()> {
|
||||
// Subscribe to paste announcements
|
||||
ctx.subscribe("new-paste").await?;
|
||||
println!("[pastebin] App started! Listening for pastes...");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_gossip_message(
|
||||
&mut self,
|
||||
_ctx: &AppContext,
|
||||
topic: &str,
|
||||
from: Option<PeerId>,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
if let Ok(text) = String::from_utf8(data.to_vec()) {
|
||||
let sender = from
|
||||
.map(|p| p.to_string()[..8].to_string())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
println!("[pastebin] New paste from {}: {}", sender, text);
|
||||
|
||||
// Store locally
|
||||
let id = format!("paste-{}", self.pastes.len());
|
||||
self.pastes.insert(id.clone(), text);
|
||||
println!("[pastebin] Stored as {}", id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_direct_message(
|
||||
&mut self,
|
||||
ctx: &AppContext,
|
||||
from: PeerId,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
if let Ok(request) = String::from_utf8(data.to_vec()) {
|
||||
if request.starts_with("GET ") {
|
||||
let id = request.trim_start_matches("GET ");
|
||||
if let Some(paste) = self.pastes.get(id) {
|
||||
println!("[pastebin] {} requested paste {}", from, id);
|
||||
ctx.send_message(from, paste.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_peer_discovered(&mut self, _ctx: &AppContext, peer: PeerId) -> Result<()> {
|
||||
println!("[pastebin] New peer: {}", peer);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("info")
|
||||
.init();
|
||||
|
||||
println!("╔══════════════════════════════════════╗");
|
||||
println!("║ TIWD Pastebin — Example App ║");
|
||||
println!("╚══════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
// Create the runtime with default config
|
||||
let mut runtime = AppRuntime::new(NodeConfig::default()).await?;
|
||||
|
||||
// Register our app — that's it!
|
||||
runtime.register(Box::new(PastebinApp::new()))?;
|
||||
|
||||
// Run forever
|
||||
runtime.run().await
|
||||
}
|
||||
196
crates/tiwd-sdk/src/app.rs
Normal file
196
crates/tiwd-sdk/src/app.rs
Normal file
@ -0,0 +1,196 @@
|
||||
use crate::context::AppContext;
|
||||
use anyhow::Result;
|
||||
use libp2p::PeerId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── App Trait ──────────────────────────────────────────────────────
|
||||
|
||||
/// The trait every TIWD app must implement.
|
||||
///
|
||||
/// This is the only interface a developer needs to build an app on TIWD.
|
||||
/// The network handles discovery, encryption, trust, and transport —
|
||||
/// the app just handles its own logic.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use tiwd_sdk::prelude::*;
|
||||
///
|
||||
/// struct Pastebin;
|
||||
///
|
||||
/// #[async_trait]
|
||||
/// impl TiwdApp for Pastebin {
|
||||
/// fn manifest(&self) -> AppManifest {
|
||||
/// AppManifest::new("pastebin", "1.0.0")
|
||||
/// .with_description("Decentralized paste sharing")
|
||||
/// .with_gossip_topics(vec!["pastes"])
|
||||
/// .with_dht_access(true)
|
||||
/// .with_direct_messaging(true)
|
||||
/// }
|
||||
///
|
||||
/// async fn on_start(&mut self, ctx: &AppContext) -> Result<()> {
|
||||
/// println!("Pastebin app started!");
|
||||
/// Ok(())
|
||||
/// }
|
||||
///
|
||||
/// async fn on_direct_message(
|
||||
/// &mut self, ctx: &AppContext, from: PeerId, data: &[u8],
|
||||
/// ) -> Result<()> {
|
||||
/// // Someone sent us a paste request
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[async_trait::async_trait]
|
||||
pub trait TiwdApp: Send + Sync {
|
||||
/// Declare what this app needs from the network.
|
||||
/// This is checked at registration time.
|
||||
fn manifest(&self) -> AppManifest;
|
||||
|
||||
/// Called when the app is started and the network is ready.
|
||||
/// Use this to subscribe to topics, set up state, etc.
|
||||
async fn on_start(&mut self, ctx: &AppContext) -> Result<()> {
|
||||
let _ = ctx;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called when the app is shutting down.
|
||||
async fn on_stop(&mut self, ctx: &AppContext) -> Result<()> {
|
||||
let _ = ctx;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called when a direct message arrives for this app.
|
||||
async fn on_direct_message(
|
||||
&mut self,
|
||||
ctx: &AppContext,
|
||||
from: PeerId,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
let _ = (ctx, from, data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called when a gossipsub message arrives on one of this app's topics.
|
||||
async fn on_gossip_message(
|
||||
&mut self,
|
||||
ctx: &AppContext,
|
||||
topic: &str,
|
||||
from: Option<PeerId>,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
let _ = (ctx, topic, from, data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called when a new peer is discovered on the network.
|
||||
async fn on_peer_discovered(&mut self, ctx: &AppContext, peer: PeerId) -> Result<()> {
|
||||
let _ = (ctx, peer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called when a peer disconnects.
|
||||
async fn on_peer_disconnected(&mut self, ctx: &AppContext, peer: PeerId) -> Result<()> {
|
||||
let _ = (ctx, peer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called when a DHT lookup completes.
|
||||
async fn on_dht_value_found(
|
||||
&mut self,
|
||||
ctx: &AppContext,
|
||||
key: &[u8],
|
||||
value: &[u8],
|
||||
) -> Result<()> {
|
||||
let _ = (ctx, key, value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── App Manifest ───────────────────────────────────────────────────
|
||||
|
||||
/// Declares what an app needs from the network.
|
||||
/// This is the "contract" between the app and the TIWD platform.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppManifest {
|
||||
/// Unique app identifier (e.g., "chat", "fileshare", "pastebin").
|
||||
/// Used to namespace protocols: `/tiwd/app/{app_id}/1.0.0`
|
||||
pub app_id: String,
|
||||
|
||||
/// Semantic version of this app.
|
||||
pub version: String,
|
||||
|
||||
/// Human-readable description.
|
||||
pub description: String,
|
||||
|
||||
/// GossipSub topics this app wants to use.
|
||||
/// Will be namespaced: `tiwd/app/{app_id}/{topic}`
|
||||
pub gossip_topics: Vec<String>,
|
||||
|
||||
/// Whether this app needs direct peer-to-peer messaging.
|
||||
pub uses_direct_messaging: bool,
|
||||
|
||||
/// Whether this app needs to read/write the DHT.
|
||||
pub uses_dht: bool,
|
||||
|
||||
/// Whether this app needs access to trust scores.
|
||||
pub uses_trust: bool,
|
||||
|
||||
/// Minimum trust score required to use this app (0 = anyone).
|
||||
pub min_trust_score: f64,
|
||||
}
|
||||
|
||||
impl AppManifest {
|
||||
pub fn new(app_id: &str, version: &str) -> Self {
|
||||
Self {
|
||||
app_id: app_id.to_string(),
|
||||
version: version.to_string(),
|
||||
description: String::new(),
|
||||
gossip_topics: Vec::new(),
|
||||
uses_direct_messaging: false,
|
||||
uses_dht: false,
|
||||
uses_trust: false,
|
||||
min_trust_score: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, desc: &str) -> Self {
|
||||
self.description = desc.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_gossip_topics(mut self, topics: Vec<&str>) -> Self {
|
||||
self.gossip_topics = topics.into_iter().map(String::from).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_direct_messaging(mut self, enabled: bool) -> Self {
|
||||
self.uses_direct_messaging = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_dht_access(mut self, enabled: bool) -> Self {
|
||||
self.uses_dht = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_trust_access(mut self, enabled: bool) -> Self {
|
||||
self.uses_trust = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_min_trust(mut self, score: f64) -> Self {
|
||||
self.min_trust_score = score;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the fully namespaced gossip topic for this app.
|
||||
pub fn full_topic(&self, topic: &str) -> String {
|
||||
format!("tiwd/app/{}/{}", self.app_id, topic)
|
||||
}
|
||||
|
||||
/// Get the protocol ID for direct messaging.
|
||||
pub fn protocol_id(&self) -> String {
|
||||
format!("/tiwd/app/{}/{}", self.app_id, self.version)
|
||||
}
|
||||
}
|
||||
201
crates/tiwd-sdk/src/context.rs
Normal file
201
crates/tiwd-sdk/src/context.rs
Normal file
@ -0,0 +1,201 @@
|
||||
use anyhow::Result;
|
||||
use libp2p::PeerId;
|
||||
use tiwd_core::network::NetworkCommand;
|
||||
use tiwd_core::NodeIdentity;
|
||||
use tiwd_trust::{NodeRole, TrustScore};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
||||
// ── App Context ────────────────────────────────────────────────────
|
||||
|
||||
/// The API surface available to every TIWD app.
|
||||
///
|
||||
/// This is what the app uses to interact with the network.
|
||||
/// All networking details are abstracted away — the app just calls methods.
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // Send a message to a peer
|
||||
/// ctx.send_message(peer_id, b"hello").await?;
|
||||
///
|
||||
/// // Publish to a gossip topic
|
||||
/// ctx.publish("announcements", b"new version available").await?;
|
||||
///
|
||||
/// // Store data in the DHT
|
||||
/// ctx.dht_put(b"my-key", b"my-value").await?;
|
||||
///
|
||||
/// // Check a peer's trust
|
||||
/// let trust = ctx.get_peer_trust(&peer_id);
|
||||
/// ```
|
||||
pub struct AppContext {
|
||||
/// The app's namespaced ID (e.g., "chat", "fileshare").
|
||||
app_id: String,
|
||||
/// Channel to send commands to the network layer.
|
||||
command_tx: mpsc::Sender<NetworkCommand>,
|
||||
/// This node's peer ID.
|
||||
local_peer_id: PeerId,
|
||||
/// This node's trust score.
|
||||
local_trust: TrustScore,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
pub fn new(
|
||||
app_id: String,
|
||||
command_tx: mpsc::Sender<NetworkCommand>,
|
||||
local_peer_id: PeerId,
|
||||
local_trust: TrustScore,
|
||||
) -> Self {
|
||||
Self {
|
||||
app_id,
|
||||
command_tx,
|
||||
local_peer_id,
|
||||
local_trust,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity ───────────────────────────────────────────────
|
||||
|
||||
/// Get this node's peer ID.
|
||||
pub fn peer_id(&self) -> PeerId {
|
||||
self.local_peer_id
|
||||
}
|
||||
|
||||
/// Get this app's ID.
|
||||
pub fn app_id(&self) -> &str {
|
||||
&self.app_id
|
||||
}
|
||||
|
||||
// ── Messaging ──────────────────────────────────────────────
|
||||
|
||||
/// Send a direct message to a specific peer.
|
||||
/// The message is namespaced to this app automatically.
|
||||
pub async fn send_message(&self, peer: PeerId, data: &[u8]) -> Result<()> {
|
||||
let message = tiwd_core::network::DirectMessage {
|
||||
from: self.local_peer_id.to_string(),
|
||||
payload: self.wrap_app_message(data),
|
||||
timestamp: chrono::Utc::now().timestamp(),
|
||||
signature: Vec::new(), // TODO: sign with node identity
|
||||
};
|
||||
|
||||
self.command_tx
|
||||
.send(NetworkCommand::SendDirectMessage { peer, message })
|
||||
.await?;
|
||||
|
||||
debug!("[{}] Sent DM to {}", self.app_id, peer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a message to a gossip topic.
|
||||
/// Topic is automatically namespaced: `tiwd/app/{app_id}/{topic}`
|
||||
pub async fn publish(&self, topic: &str, data: &[u8]) -> Result<()> {
|
||||
let full_topic = self.full_topic(topic);
|
||||
self.command_tx
|
||||
.send(NetworkCommand::PublishGossip {
|
||||
topic: full_topic.clone(),
|
||||
data: self.wrap_app_message(data),
|
||||
})
|
||||
.await?;
|
||||
|
||||
debug!("[{}] Published to {}", self.app_id, full_topic);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribe to a gossip topic.
|
||||
/// Topic is automatically namespaced.
|
||||
pub async fn subscribe(&self, topic: &str) -> Result<()> {
|
||||
let full_topic = self.full_topic(topic);
|
||||
self.command_tx
|
||||
.send(NetworkCommand::Subscribe {
|
||||
topic: full_topic.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
debug!("[{}] Subscribed to {}", self.app_id, full_topic);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── DHT (Distributed Storage) ──────────────────────────────
|
||||
|
||||
/// Store a key-value pair in the DHT.
|
||||
/// Key is automatically namespaced to this app.
|
||||
pub async fn dht_put(&self, key: &[u8], value: &[u8]) -> Result<()> {
|
||||
let full_key = self.namespaced_key(key);
|
||||
self.command_tx
|
||||
.send(NetworkCommand::DhtPut {
|
||||
key: full_key,
|
||||
value: value.to_vec(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a value in the DHT.
|
||||
/// Key is automatically namespaced to this app.
|
||||
/// Result arrives via `on_dht_value_found` callback.
|
||||
pub async fn dht_get(&self, key: &[u8]) -> Result<()> {
|
||||
let full_key = self.namespaced_key(key);
|
||||
self.command_tx
|
||||
.send(NetworkCommand::DhtGet { key: full_key })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Trust ──────────────────────────────────────────────────
|
||||
|
||||
/// Get this node's current trust score.
|
||||
pub fn local_trust(&self) -> &TrustScore {
|
||||
&self.local_trust
|
||||
}
|
||||
|
||||
/// Get this node's role.
|
||||
pub fn local_role(&self) -> NodeRole {
|
||||
self.local_trust.role()
|
||||
}
|
||||
|
||||
/// Check if this node meets a minimum trust threshold.
|
||||
pub fn meets_trust_threshold(&self, min_score: f64) -> bool {
|
||||
self.local_trust.total() >= min_score
|
||||
}
|
||||
|
||||
// ── Connectivity ───────────────────────────────────────────
|
||||
|
||||
/// Connect to a specific peer by multiaddress.
|
||||
pub async fn dial(&self, addr: &str) -> Result<()> {
|
||||
let multiaddr: libp2p::Multiaddr = addr.parse()?;
|
||||
self.command_tx
|
||||
.send(NetworkCommand::Dial { addr: multiaddr })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────
|
||||
|
||||
fn full_topic(&self, topic: &str) -> String {
|
||||
format!("tiwd/app/{}/{}", self.app_id, topic)
|
||||
}
|
||||
|
||||
fn namespaced_key(&self, key: &[u8]) -> Vec<u8> {
|
||||
let prefix = format!("tiwd/app/{}/", self.app_id);
|
||||
let mut full_key = prefix.into_bytes();
|
||||
full_key.extend_from_slice(key);
|
||||
full_key
|
||||
}
|
||||
|
||||
fn wrap_app_message(&self, data: &[u8]) -> Vec<u8> {
|
||||
// Wrap with app metadata so the receiving node knows which app to route to
|
||||
let wrapper = AppMessage {
|
||||
app_id: self.app_id.clone(),
|
||||
sender: self.local_peer_id.to_string(),
|
||||
data: data.to_vec(),
|
||||
};
|
||||
serde_json::to_vec(&wrapper).unwrap_or_else(|_| data.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal message wrapper that tags every message with its source app.
|
||||
/// The node uses this to route incoming messages to the correct app.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AppMessage {
|
||||
pub app_id: String,
|
||||
pub sender: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
19
crates/tiwd-sdk/src/lib.rs
Normal file
19
crates/tiwd-sdk/src/lib.rs
Normal file
@ -0,0 +1,19 @@
|
||||
pub mod app;
|
||||
pub mod context;
|
||||
pub mod runtime;
|
||||
|
||||
pub use app::{AppManifest, TiwdApp};
|
||||
pub use context::AppContext;
|
||||
pub use runtime::AppRuntime;
|
||||
|
||||
/// Re-exports for convenience.
|
||||
pub mod prelude {
|
||||
pub use crate::app::{AppManifest, TiwdApp};
|
||||
pub use crate::context::AppContext;
|
||||
pub use crate::runtime::AppRuntime;
|
||||
pub use anyhow::Result;
|
||||
pub use async_trait::async_trait;
|
||||
pub use libp2p::PeerId;
|
||||
pub use tiwd_core::NodeConfig;
|
||||
pub use tiwd_trust::{NodeRole, TrustScore};
|
||||
}
|
||||
249
crates/tiwd-sdk/src/runtime.rs
Normal file
249
crates/tiwd-sdk/src/runtime.rs
Normal file
@ -0,0 +1,249 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tiwd_core::network::{NetworkCommand, NetworkEvent, NodeNetwork};
|
||||
use tiwd_core::{NodeConfig, NodeIdentity};
|
||||
use tiwd_trust::TrustScore;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::app::TiwdApp;
|
||||
use crate::context::{AppContext, AppMessage};
|
||||
|
||||
// ── App Runtime ────────────────────────────────────────────────────
|
||||
|
||||
/// The TIWD app runtime. Manages the network and routes events to registered apps.
|
||||
///
|
||||
/// This is what ties everything together. Developers create a runtime,
|
||||
/// register their apps, and call `run()`. The runtime handles the rest.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use tiwd_sdk::prelude::*;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<()> {
|
||||
/// let mut runtime = AppRuntime::new(NodeConfig::default()).await?;
|
||||
/// runtime.register(MyApp::new())?;
|
||||
/// runtime.run().await
|
||||
/// }
|
||||
/// ```
|
||||
pub struct AppRuntime {
|
||||
config: NodeConfig,
|
||||
identity: NodeIdentity,
|
||||
trust: TrustScore,
|
||||
apps: HashMap<String, Box<dyn TiwdApp>>,
|
||||
event_rx: mpsc::Receiver<NetworkEvent>,
|
||||
command_tx: mpsc::Sender<NetworkCommand>,
|
||||
command_rx_for_network: Option<mpsc::Receiver<NetworkCommand>>,
|
||||
}
|
||||
|
||||
impl AppRuntime {
|
||||
/// Create a new app runtime with the given config.
|
||||
pub async fn new(config: NodeConfig) -> Result<Self> {
|
||||
let identity = NodeIdentity::load_or_generate(&config.data_dir)?;
|
||||
let trust = TrustScore::new();
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel::<NetworkEvent>(256);
|
||||
let (command_tx, command_rx) = mpsc::channel::<NetworkCommand>(256);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
identity,
|
||||
trust,
|
||||
apps: HashMap::new(),
|
||||
event_rx,
|
||||
command_tx,
|
||||
command_rx_for_network: Some(command_rx),
|
||||
})
|
||||
}
|
||||
|
||||
/// Register an app with the runtime.
|
||||
/// The app's manifest is validated and its topics are set up.
|
||||
pub fn register(&mut self, app: Box<dyn TiwdApp>) -> Result<()> {
|
||||
let manifest = app.manifest();
|
||||
let app_id = manifest.app_id.clone();
|
||||
|
||||
// Check for duplicate registration
|
||||
if self.apps.contains_key(&app_id) {
|
||||
anyhow::bail!("App '{}' is already registered", app_id);
|
||||
}
|
||||
|
||||
// Check trust requirements
|
||||
if self.trust.total() < manifest.min_trust_score {
|
||||
anyhow::bail!(
|
||||
"App '{}' requires trust score {}, but this node has {}",
|
||||
app_id,
|
||||
manifest.min_trust_score,
|
||||
self.trust.total()
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Registered app: {} v{} ({})",
|
||||
app_id, manifest.version, manifest.description
|
||||
);
|
||||
|
||||
self.apps.insert(app_id, app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get this node's peer ID.
|
||||
pub fn peer_id(&self) -> libp2p::PeerId {
|
||||
self.identity.peer_id()
|
||||
}
|
||||
|
||||
/// Get this node's trust score.
|
||||
pub fn trust(&self) -> &TrustScore {
|
||||
&self.trust
|
||||
}
|
||||
|
||||
/// Run the runtime. This starts the network and routes events to apps.
|
||||
/// This method runs forever (until the process is killed).
|
||||
pub async fn run(&mut self) -> Result<()> {
|
||||
let peer_id = self.identity.peer_id();
|
||||
let listen_port = self.config.listen_port;
|
||||
|
||||
// Take the command receiver (can only be taken once)
|
||||
let command_rx = self
|
||||
.command_rx_for_network
|
||||
.take()
|
||||
.context("Runtime already started")?;
|
||||
|
||||
// Create the network
|
||||
let event_tx = {
|
||||
let (tx, rx) = mpsc::channel::<NetworkEvent>(256);
|
||||
// Replace our event_rx with the new one
|
||||
// We need a fresh event_tx for the network
|
||||
self.event_rx = rx;
|
||||
tx
|
||||
};
|
||||
|
||||
let mut network =
|
||||
NodeNetwork::new(&self.identity, listen_port, event_tx, command_rx)?;
|
||||
|
||||
// Start all registered apps
|
||||
for (app_id, app) in self.apps.iter_mut() {
|
||||
let ctx = AppContext::new(
|
||||
app_id.clone(),
|
||||
self.command_tx.clone(),
|
||||
peer_id,
|
||||
self.trust.clone(),
|
||||
);
|
||||
if let Err(e) = app.on_start(&ctx).await {
|
||||
error!("App '{}' failed to start: {}", app_id, e);
|
||||
} else {
|
||||
info!("App '{}' started", app_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the network event loop
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = network.run(listen_port).await {
|
||||
error!("Network error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
info!("TIWD runtime started with {} app(s)", self.apps.len());
|
||||
|
||||
// Route events to apps
|
||||
while let Some(event) = self.event_rx.recv().await {
|
||||
self.dispatch_event(event).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch_event(&mut self, event: NetworkEvent) {
|
||||
// Extract fields needed for AppContext so we don't borrow self while iterating apps
|
||||
let cmd_tx = &self.command_tx;
|
||||
let peer_id = self.identity.peer_id();
|
||||
let trust = &self.trust;
|
||||
|
||||
let make_ctx = |app_id: &str| -> AppContext {
|
||||
AppContext::new(
|
||||
app_id.to_string(),
|
||||
cmd_tx.clone(),
|
||||
peer_id,
|
||||
trust.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
match event {
|
||||
NetworkEvent::PeerDiscovered(peer) => {
|
||||
for (app_id, app) in self.apps.iter_mut() {
|
||||
let ctx = make_ctx(app_id);
|
||||
if let Err(e) = app.on_peer_discovered(&ctx, peer).await {
|
||||
warn!("App '{}' error on peer discovered: {}", app_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NetworkEvent::PeerDisconnected(peer) => {
|
||||
for (app_id, app) in self.apps.iter_mut() {
|
||||
let ctx = make_ctx(app_id);
|
||||
if let Err(e) = app.on_peer_disconnected(&ctx, peer).await {
|
||||
warn!("App '{}' error on peer disconnected: {}", app_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NetworkEvent::DirectMessage { from, message } => {
|
||||
if let Ok(app_msg) = serde_json::from_slice::<AppMessage>(&message.payload) {
|
||||
if let Some(app) = self.apps.get_mut(&app_msg.app_id) {
|
||||
let ctx = make_ctx(&app_msg.app_id);
|
||||
if let Err(e) = app.on_direct_message(&ctx, from, &app_msg.data).await {
|
||||
warn!("App '{}' error on DM: {}", app_msg.app_id, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (app_id, app) in self.apps.iter_mut() {
|
||||
let ctx = make_ctx(app_id);
|
||||
if let Err(e) = app.on_direct_message(&ctx, from, &message.payload).await {
|
||||
warn!("App '{}' error on DM: {}", app_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NetworkEvent::GossipMessage { topic, from, data } => {
|
||||
if let Ok(app_msg) = serde_json::from_slice::<AppMessage>(&data) {
|
||||
if let Some(app) = self.apps.get_mut(&app_msg.app_id) {
|
||||
let ctx = make_ctx(&app_msg.app_id);
|
||||
if let Err(e) = app
|
||||
.on_gossip_message(&ctx, &topic, from, &app_msg.data)
|
||||
.await
|
||||
{
|
||||
warn!("App '{}' error on gossip: {}", app_msg.app_id, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (app_id, app) in self.apps.iter_mut() {
|
||||
let prefix = format!("tiwd/app/{}/", app_id);
|
||||
if topic.starts_with(&prefix) || topic.contains(app_id.as_str()) {
|
||||
let ctx = make_ctx(app_id);
|
||||
if let Err(e) =
|
||||
app.on_gossip_message(&ctx, &topic, from, &data).await
|
||||
{
|
||||
warn!("App '{}' error on gossip: {}", app_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NetworkEvent::DhtValueFound { key, value } => {
|
||||
for (app_id, app) in self.apps.iter_mut() {
|
||||
let prefix = format!("tiwd/app/{}/", app_id);
|
||||
if key.starts_with(prefix.as_bytes()) {
|
||||
let ctx = make_ctx(app_id);
|
||||
if let Err(e) = app.on_dht_value_found(&ctx, &key, &value).await {
|
||||
warn!("App '{}' error on DHT value: {}", app_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
crates/tiwd-trust/Cargo.toml
Normal file
16
crates/tiwd-trust/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "tiwd-trust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Trustless reputation engine for TIWD"
|
||||
|
||||
[dependencies]
|
||||
tiwd-core = { path = "../tiwd-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
libp2p = { workspace = true }
|
||||
6
crates/tiwd-trust/src/lib.rs
Normal file
6
crates/tiwd-trust/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod score;
|
||||
|
||||
pub use score::{
|
||||
FlagReason, FlagRecord, JuryVote, ModerationOutcome, ModerationVerdict, NodeRole,
|
||||
TrustScore, Vote, VouchRecord,
|
||||
};
|
||||
457
crates/tiwd-trust/src/score.rs
Normal file
457
crates/tiwd-trust/src/score.rs
Normal file
@ -0,0 +1,457 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Trust Score Constants ──────────────────────────────────────────
|
||||
|
||||
/// Maximum points from time on the network.
|
||||
const MAX_TIME_SCORE: f64 = 40.0;
|
||||
/// Maximum points from vouches.
|
||||
const MAX_VOUCH_SCORE: f64 = 35.0;
|
||||
/// Maximum points from network activity.
|
||||
const MAX_ACTIVITY_SCORE: f64 = 25.0;
|
||||
/// Trust threshold to become a Guardian (can participate in moderation).
|
||||
const GUARDIAN_THRESHOLD: f64 = 60.0;
|
||||
/// Trust threshold to become an Elder (votes count 2x).
|
||||
const ELDER_THRESHOLD: f64 = 80.0;
|
||||
/// Multiplier for trust LOSS (trust is lost 10x faster than gained).
|
||||
const TRUST_LOSS_MULTIPLIER: f64 = 10.0;
|
||||
/// Maximum vouches a node can give per 30-day period.
|
||||
const MAX_VOUCHES_PER_PERIOD: u32 = 5;
|
||||
/// Trust cost to the voucher when vouching for someone.
|
||||
const VOUCH_COST: f64 = 2.0;
|
||||
/// Trust penalty to the voucher if their vouchee is banned.
|
||||
const VOUCH_PENALTY_ON_BAN: f64 = 10.0;
|
||||
/// Trust cost to file a flag against another node.
|
||||
const FLAG_COST: f64 = 1.0;
|
||||
/// Number of jurors for a standard moderation case.
|
||||
const JURY_SIZE: usize = 7;
|
||||
/// Number of jurors for an appeal.
|
||||
const APPEAL_JURY_SIZE: usize = 11;
|
||||
|
||||
// ── Node Role ──────────────────────────────────────────────────────
|
||||
|
||||
/// The role a node holds on the network, determined solely by trust score.
|
||||
/// Roles cannot be assigned, bought, or transferred — only earned.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NodeRole {
|
||||
/// Just joined. Cannot vouch, cannot moderate. Score < 20.
|
||||
Newcomer,
|
||||
/// Active participant. Can vouch for others. Score 20-59.
|
||||
Member,
|
||||
/// Trusted node. Can participate in moderation juries. Score 60-79.
|
||||
Guardian,
|
||||
/// Highly trusted. Votes count 2x in moderation. Score 80+.
|
||||
Elder,
|
||||
}
|
||||
|
||||
impl NodeRole {
|
||||
pub fn from_score(score: f64) -> Self {
|
||||
if score >= ELDER_THRESHOLD {
|
||||
NodeRole::Elder
|
||||
} else if score >= GUARDIAN_THRESHOLD {
|
||||
NodeRole::Guardian
|
||||
} else if score >= 20.0 {
|
||||
NodeRole::Member
|
||||
} else {
|
||||
NodeRole::Newcomer
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_vouch(&self) -> bool {
|
||||
matches!(self, NodeRole::Member | NodeRole::Guardian | NodeRole::Elder)
|
||||
}
|
||||
|
||||
pub fn can_moderate(&self) -> bool {
|
||||
matches!(self, NodeRole::Guardian | NodeRole::Elder)
|
||||
}
|
||||
|
||||
pub fn vote_weight(&self) -> u32 {
|
||||
match self {
|
||||
NodeRole::Elder => 2,
|
||||
NodeRole::Guardian => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trust Score ────────────────────────────────────────────────────
|
||||
|
||||
/// The complete trust profile for a node on the TIWD network.
|
||||
/// This is computed locally by every node — no central authority.
|
||||
/// Any node can independently verify any other node's trust score
|
||||
/// by reading the signed records in the DHT.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrustScore {
|
||||
/// Points earned from continuous time on the network (0-40).
|
||||
/// Grows logarithmically: 10 * log2(days + 1), capped at 40.
|
||||
/// This is the dominant factor and CANNOT be purchased.
|
||||
pub time_score: f64,
|
||||
|
||||
/// Points earned from vouches by other trusted nodes (0-35).
|
||||
/// Each vouch is worth: voucher_trust * 0.1 (max 5 per vouch).
|
||||
/// Vouching COSTS the voucher 2 points (skin in the game).
|
||||
pub vouch_score: f64,
|
||||
|
||||
/// Points earned from network participation (0-25).
|
||||
/// Based on consistent uptime, DHT routing, and message relay.
|
||||
/// Measures CONSISTENCY, not volume (prevents gaming).
|
||||
pub activity_score: f64,
|
||||
|
||||
/// Accumulated penalties from moderation actions.
|
||||
pub penalties: f64,
|
||||
|
||||
/// When this node first appeared on the network.
|
||||
pub first_seen: DateTime<Utc>,
|
||||
|
||||
/// Total days of active participation.
|
||||
pub active_days: u64,
|
||||
|
||||
/// Number of vouches received.
|
||||
pub vouches_received: u32,
|
||||
|
||||
/// Number of vouches given (rate-limited).
|
||||
pub vouches_given_this_period: u32,
|
||||
|
||||
/// When the current vouch period started.
|
||||
pub vouch_period_start: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TrustScore {
|
||||
/// Create a trust profile for a brand new node.
|
||||
/// Starts at zero — trust must be earned.
|
||||
pub fn new() -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
time_score: 0.0,
|
||||
vouch_score: 0.0,
|
||||
activity_score: 0.0,
|
||||
penalties: 0.0,
|
||||
first_seen: now,
|
||||
active_days: 0,
|
||||
vouches_received: 0,
|
||||
vouches_given_this_period: 0,
|
||||
vouch_period_start: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// The total trust score (0-100, minus penalties).
|
||||
pub fn total(&self) -> f64 {
|
||||
let raw = self.time_score + self.vouch_score + self.activity_score;
|
||||
(raw - self.penalties).max(0.0)
|
||||
}
|
||||
|
||||
/// The node's current role based on trust score.
|
||||
pub fn role(&self) -> NodeRole {
|
||||
NodeRole::from_score(self.total())
|
||||
}
|
||||
|
||||
/// Recalculate the time score based on active days.
|
||||
/// Formula: 10 * log2(days + 1), capped at 40.
|
||||
/// This gives:
|
||||
/// Day 1: 10.0
|
||||
/// Day 7: 30.0
|
||||
/// Day 30: 34.9
|
||||
/// Day 365: 38.7
|
||||
/// Day 1000: 40.0 (cap)
|
||||
pub fn recalculate_time_score(&mut self) {
|
||||
let days = self.active_days as f64;
|
||||
self.time_score = (10.0 * (days + 1.0).log2()).min(MAX_TIME_SCORE);
|
||||
}
|
||||
|
||||
/// Record one more active day. Called once per day when the node
|
||||
/// is online and participating.
|
||||
pub fn record_active_day(&mut self) {
|
||||
self.active_days += 1;
|
||||
self.recalculate_time_score();
|
||||
}
|
||||
|
||||
/// Receive a vouch from another node.
|
||||
/// The value depends on the voucher's trust score.
|
||||
pub fn receive_vouch(&mut self, voucher_trust: f64) -> f64 {
|
||||
let vouch_value = (voucher_trust * 0.1).min(5.0);
|
||||
self.vouch_score = (self.vouch_score + vouch_value).min(MAX_VOUCH_SCORE);
|
||||
self.vouches_received += 1;
|
||||
vouch_value
|
||||
}
|
||||
|
||||
/// Check if this node can give a vouch (rate limited).
|
||||
pub fn can_give_vouch(&self) -> bool {
|
||||
if !self.role().can_vouch() {
|
||||
return false;
|
||||
}
|
||||
let now = Utc::now();
|
||||
let period_elapsed = now
|
||||
.signed_duration_since(self.vouch_period_start)
|
||||
.num_days();
|
||||
if period_elapsed >= 30 {
|
||||
// Period has reset
|
||||
return true;
|
||||
}
|
||||
self.vouches_given_this_period < MAX_VOUCHES_PER_PERIOD
|
||||
}
|
||||
|
||||
/// Record that this node gave a vouch. Costs trust points.
|
||||
pub fn record_vouch_given(&mut self) {
|
||||
let now = Utc::now();
|
||||
let period_elapsed = now
|
||||
.signed_duration_since(self.vouch_period_start)
|
||||
.num_days();
|
||||
if period_elapsed >= 30 {
|
||||
self.vouches_given_this_period = 0;
|
||||
self.vouch_period_start = now;
|
||||
}
|
||||
self.vouches_given_this_period += 1;
|
||||
// Vouching costs trust — skin in the game
|
||||
self.apply_penalty(VOUCH_COST);
|
||||
}
|
||||
|
||||
/// Apply penalty when a vouchee gets banned.
|
||||
/// The voucher suffers for vouching for a bad actor.
|
||||
pub fn vouchee_banned(&mut self) {
|
||||
self.apply_penalty(VOUCH_PENALTY_ON_BAN);
|
||||
}
|
||||
|
||||
/// Update activity score based on network participation metrics.
|
||||
/// Score is based on consistency (days active / days since joined)
|
||||
/// and routing participation, NOT raw volume.
|
||||
pub fn update_activity_score(&mut self, consistency_ratio: f64, routing_score: f64) {
|
||||
// Consistency: 0.0 to 1.0 (fraction of days active since joining)
|
||||
let consistency = consistency_ratio.clamp(0.0, 1.0);
|
||||
// Routing: 0.0 to 1.0 (how well this node serves DHT requests)
|
||||
let routing = routing_score.clamp(0.0, 1.0);
|
||||
|
||||
// Weight consistency more than raw routing
|
||||
self.activity_score = ((consistency * 15.0) + (routing * 10.0)).min(MAX_ACTIVITY_SCORE);
|
||||
}
|
||||
|
||||
/// Apply a trust penalty. Penalties are multiplied — trust is lost fast.
|
||||
pub fn apply_penalty(&mut self, amount: f64) {
|
||||
self.penalties += amount * TRUST_LOSS_MULTIPLIER;
|
||||
}
|
||||
|
||||
/// Apply a direct (non-multiplied) penalty for minor costs like flagging.
|
||||
pub fn apply_minor_penalty(&mut self, amount: f64) {
|
||||
self.penalties += amount;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TrustScore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vouch Record ───────────────────────────────────────────────────
|
||||
|
||||
/// A signed vouch from one node for another.
|
||||
/// Stored in the DHT, verifiable by anyone.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VouchRecord {
|
||||
/// The node giving the vouch.
|
||||
pub voucher: String,
|
||||
/// The node receiving the vouch.
|
||||
pub vouchee: String,
|
||||
/// When the vouch was given.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// When the vouch expires (must be renewed yearly).
|
||||
pub expires: DateTime<Utc>,
|
||||
/// The voucher's trust score at time of vouching.
|
||||
pub voucher_trust_at_time: f64,
|
||||
/// Cryptographic signature by the voucher.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
// ── Flag Record ────────────────────────────────────────────────────
|
||||
|
||||
/// A flag raised against a node for bad behavior.
|
||||
/// Costs the flagger 1 trust point to prevent spam.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlagRecord {
|
||||
/// The node raising the flag.
|
||||
pub flagger: String,
|
||||
/// The node being flagged.
|
||||
pub flagged: String,
|
||||
/// Reason category.
|
||||
pub reason: FlagReason,
|
||||
/// Free-text evidence/description.
|
||||
pub evidence: String,
|
||||
/// When the flag was raised.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Signature by the flagger.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FlagReason {
|
||||
Spam,
|
||||
Harassment,
|
||||
IllegalContent,
|
||||
NetworkAbuse,
|
||||
SybilAttack,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
// ── Moderation Verdict ─────────────────────────────────────────────
|
||||
|
||||
/// The outcome of a moderation jury vote.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModerationVerdict {
|
||||
/// The flagged node.
|
||||
pub accused: String,
|
||||
/// The jury members and their votes.
|
||||
pub jury_votes: Vec<JuryVote>,
|
||||
/// The verdict.
|
||||
pub outcome: ModerationOutcome,
|
||||
/// When the verdict was reached.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// How many prior offenses this node has.
|
||||
pub offense_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JuryVote {
|
||||
pub juror: String,
|
||||
pub vote: Vote,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum Vote {
|
||||
Guilty,
|
||||
NotGuilty,
|
||||
Abstain,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ModerationOutcome {
|
||||
/// Not enough evidence. Flagger may lose trust for frivolous flag.
|
||||
Acquitted,
|
||||
/// 24-hour mute (1st offense).
|
||||
Muted { until: DateTime<Utc> },
|
||||
/// 7-day suspension (2nd offense).
|
||||
Suspended { until: DateTime<Utc> },
|
||||
/// 30-day ban (3rd offense).
|
||||
Banned { until: DateTime<Utc> },
|
||||
/// Permanent ban (4th+ offense or extreme content).
|
||||
PermanentBan,
|
||||
}
|
||||
|
||||
impl ModerationOutcome {
|
||||
/// Determine punishment based on offense count.
|
||||
pub fn for_offense(count: u32) -> Self {
|
||||
let now = Utc::now();
|
||||
match count {
|
||||
0 | 1 => ModerationOutcome::Muted {
|
||||
until: now + chrono::Duration::hours(24),
|
||||
},
|
||||
2 => ModerationOutcome::Suspended {
|
||||
until: now + chrono::Duration::days(7),
|
||||
},
|
||||
3 => ModerationOutcome::Banned {
|
||||
until: now + chrono::Duration::days(30),
|
||||
},
|
||||
_ => ModerationOutcome::PermanentBan,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_node_starts_at_zero() {
|
||||
let score = TrustScore::new();
|
||||
assert_eq!(score.total(), 0.0);
|
||||
assert_eq!(score.role(), NodeRole::Newcomer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_score_grows_logarithmically() {
|
||||
let mut score = TrustScore::new();
|
||||
|
||||
// Day 1
|
||||
score.active_days = 1;
|
||||
score.recalculate_time_score();
|
||||
assert!((score.time_score - 10.0).abs() < 0.01);
|
||||
|
||||
// Day 7
|
||||
score.active_days = 7;
|
||||
score.recalculate_time_score();
|
||||
assert!((score.time_score - 30.0).abs() < 0.01);
|
||||
|
||||
// Day 1000 — should cap at 40
|
||||
score.active_days = 1000;
|
||||
score.recalculate_time_score();
|
||||
assert_eq!(score.time_score, MAX_TIME_SCORE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vouch_score_capped() {
|
||||
let mut score = TrustScore::new();
|
||||
// Receive many vouches from highly trusted nodes
|
||||
for _ in 0..100 {
|
||||
score.receive_vouch(100.0);
|
||||
}
|
||||
assert!(score.vouch_score <= MAX_VOUCH_SCORE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_lost_faster_than_gained() {
|
||||
let mut score = TrustScore::new();
|
||||
score.active_days = 30;
|
||||
score.recalculate_time_score();
|
||||
let before = score.total();
|
||||
|
||||
// One penalty wipes a lot of trust
|
||||
score.apply_penalty(3.0); // 3 * 10x = 30 points lost
|
||||
assert!(score.total() < before - 25.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newcomer_cannot_vouch() {
|
||||
let score = TrustScore::new();
|
||||
assert!(!score.role().can_vouch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_can_moderate() {
|
||||
let mut score = TrustScore::new();
|
||||
score.active_days = 365;
|
||||
score.recalculate_time_score();
|
||||
score.vouch_score = 20.0;
|
||||
score.activity_score = 10.0;
|
||||
// Total ~69 — Guardian level
|
||||
assert!(score.role().can_moderate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sybil_attack_worthless() {
|
||||
// 1000 brand new nodes = 1000 * 0 trust = 0 power
|
||||
let accounts: Vec<TrustScore> = (0..1000).map(|_| TrustScore::new()).collect();
|
||||
let total_power: f64 = accounts.iter().map(|a| a.total()).sum();
|
||||
assert_eq!(total_power, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graduated_punishment() {
|
||||
assert!(matches!(
|
||||
ModerationOutcome::for_offense(1),
|
||||
ModerationOutcome::Muted { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ModerationOutcome::for_offense(2),
|
||||
ModerationOutcome::Suspended { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ModerationOutcome::for_offense(3),
|
||||
ModerationOutcome::Banned { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ModerationOutcome::for_offense(4),
|
||||
ModerationOutcome::PermanentBan
|
||||
));
|
||||
}
|
||||
}
|
||||
BIN
first-message.png
Normal file
BIN
first-message.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
9
run-node.bat
Normal file
9
run-node.bat
Normal file
@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
set TIWD_DATA_DIR=C:\Users\Admin\tiwd-local
|
||||
set TIWD_PORT=9004
|
||||
set TIWD_BOOTSTRAP=/ip4/185.193.125.79/tcp/9000
|
||||
set TIWD_NAME=thefounder
|
||||
set RUST_LOG=warn
|
||||
cd C:\Users\Admin\tiwd
|
||||
target\debug\tiwd-node.exe
|
||||
pause
|
||||
Loading…
Reference in New Issue
Block a user