Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion node/beacon_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
import hmac
import math
import os
import re
import time
import hashlib
import sqlite3
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from flask import Blueprint, jsonify, request, g
from flask import Blueprint, Response, jsonify, request, g

try:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
Expand All @@ -23,6 +27,22 @@

DB_PATH = 'rustchain_v2.db'
BEACON_AUTH_WINDOW_SECONDS = 300
BOTTUBE_AVATAR_BASE_URL = 'https://bottube.ai/avatar/'
BOTTUBE_AVATAR_MAX_BYTES = 1024 * 1024
BOTTUBE_AVATAR_FILENAME = re.compile(
r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,126}\.(?:svg|png|jpe?g|webp)$',
re.IGNORECASE,
)
BOTTUBE_AVATAR_CONTENT_TYPES = frozenset({
'image/svg+xml', 'image/png', 'image/jpeg', 'image/webp',
})


class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Keep the avatar proxy pinned to its configured upstream origin."""

def redirect_request(self, req, fp, code, msg, headers, newurl):
return None

# Statuses an administrator uses to bar an agent. A rejoin via /beacon/join
# must never silently lift one of these — otherwise any holder of the
Expand Down Expand Up @@ -1415,6 +1435,57 @@ def relay_discover():
return jsonify([])


# ============================================================
# AGENT AVATAR TEXTURE PROXY
# ============================================================

def _fetch_bottube_avatar(filename):
"""Fetch one bounded image from BoTTube's fixed public avatar origin."""
upstream_url = BOTTUBE_AVATAR_BASE_URL + urllib.parse.quote(filename, safe='')
upstream_request = urllib.request.Request(
upstream_url,
headers={
'Accept': 'image/svg+xml,image/png,image/jpeg,image/webp',
'User-Agent': 'RustChain-Beacon-Atlas/1.0',
},
)
opener = urllib.request.build_opener(_NoRedirectHandler)
with opener.open(upstream_request, timeout=5) as upstream:
content_type = upstream.headers.get_content_type().lower()
if content_type not in BOTTUBE_AVATAR_CONTENT_TYPES:
raise ValueError('unsupported avatar content type')

payload = upstream.read(BOTTUBE_AVATAR_MAX_BYTES + 1)
if not payload or len(payload) > BOTTUBE_AVATAR_MAX_BYTES:
raise ValueError('invalid avatar size')
return payload, content_type


@beacon_api.route('/api/avatar/<filename>', methods=['GET'])
def get_bottube_avatar(filename):
"""Return a WebGL-safe, same-origin copy of a public BoTTube avatar."""
if not BOTTUBE_AVATAR_FILENAME.fullmatch(filename):
return jsonify({'error': 'invalid avatar filename'}), 400

try:
payload, content_type = _fetch_bottube_avatar(filename)
except urllib.error.HTTPError as exc:
status = 404 if exc.code == 404 else 502
return jsonify({'error': 'avatar unavailable'}), status
except (urllib.error.URLError, TimeoutError, ValueError):
return jsonify({'error': 'avatar unavailable'}), 502

response = Response(payload, content_type=content_type)
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Cache-Control'] = 'public, max-age=86400'
response.headers['Content-Security-Policy'] = (
"default-src 'none'; script-src 'none'; object-src 'none'; "
"base-uri 'none'; frame-ancestors 'none'; sandbox"
)
response.headers['X-Content-Type-Options'] = 'nosniff'
return response


# ============================================================
# HEALTH CHECK
# ============================================================
Expand Down
54 changes: 52 additions & 2 deletions site/beacon/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
import * as THREE from 'three';
import {
AGENTS, GRADE_COLORS, agentCity, cityPosition, seededRandom,
getProviderColor,
getProviderColor, avatarTextureUrl,
} from './data.js';
import {
getScene, registerClickable, registerHoverable, onAnimate,
} from './scene.js';

const agentMeshes = new Map(); // agentId -> { core, glow, group }
const agentPositions = new Map(); // agentId -> Vector3
const avatarTextureLoader = new THREE.TextureLoader();
avatarTextureLoader.setCrossOrigin('anonymous');

export function getAgentPosition(agentId) {
return agentPositions.get(agentId);
Expand Down Expand Up @@ -75,6 +77,15 @@ export function buildAgents() {
registerClickable(core);
registerHoverable(core);

// Public profile images are optional. The core remains the visual and
// interaction fallback until a validated HTTPS texture loads successfully.
const avatar = makeAgentAvatar(agent);
if (avatar) {
group.add(avatar);
registerClickable(avatar);
registerHoverable(avatar);
}

// Outer glow — slightly larger for relay to emphasize presence
const glowGeo = isRelay
? new THREE.OctahedronGeometry(3.0, 1)
Expand Down Expand Up @@ -102,7 +113,7 @@ export function buildAgents() {
group.add(label);

scene.add(group);
agentMeshes.set(agent.id, { core, glow, group, light, relay: isRelay });
agentMeshes.set(agent.id, { core, glow, group, light, avatar, relay: isRelay });
}

// Bob + spin animation
Expand Down Expand Up @@ -132,6 +143,7 @@ export function highlightAgent(agentId, on) {
mesh.glow.material.opacity = on ? 0.35 : (mesh.relay ? 0.08 : 0.12);
mesh.core.material.opacity = on ? 1.0 : 0.9;
mesh.light.intensity = on ? 0.8 : (mesh.relay ? 0.4 : 0.3);
if (mesh.avatar?.visible) mesh.avatar.material.opacity = on ? 1.0 : 0.94;
}

function countAgentsInCity(cityId) {
Expand All @@ -146,6 +158,44 @@ function hashCode(str) {
return Math.abs(h);
}

function makeAgentAvatar(agent) {
const avatarUrl = avatarTextureUrl(agent.avatar);
if (!avatarUrl) return null;

const material = new THREE.SpriteMaterial({
transparent: true,
opacity: 0.94,
depthTest: false,
depthWrite: false,
alphaTest: 0.08,
});
const sprite = new THREE.Sprite(material);
sprite.position.set(0, 0, 0);
sprite.scale.set(3.2, 3.2, 1);
sprite.renderOrder = 3;
sprite.visible = false;
sprite.userData = { type: 'agent', agentId: agent.id, avatar: true };

avatarTextureLoader.load(
avatarUrl,
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
material.map = texture;
material.needsUpdate = true;
sprite.visible = true;
},
undefined,
() => {
material.map = null;
material.needsUpdate = true;
sprite.visible = false;
},
);

return sprite;
}

function makeAgentLabel(text, color, isRelay = false) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
Expand Down
79 changes: 66 additions & 13 deletions site/beacon/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,11 @@ function cityFromCategories(cats) {
// ============================================================
// Convert a BoTTube API agent into an Atlas agent
// ============================================================
export function normalizeBottubeAgents(payload) {
if (Array.isArray(payload)) return payload;
return Array.isArray(payload?.agents) ? payload.agents : [];
}

function bottubeToAtlas(bt) {
const legacy = LEGACY_AGENT_OVERRIDES[bt.agent_name];
const id = legacy ? legacy.id : makeGeneratedId('bcn_bt', bt.agent_name);
Expand Down Expand Up @@ -566,20 +571,33 @@ export async function fetchAllAgents(apiBase) {
}

// --- 1. BoTTube agents ---
try {
const resp = await fetch(`${bottubeBase}/api/atlas/agents`);
if (resp.ok) {
const btAgents = await resp.json();
for (const bt of btAgents) {
if (shouldSkip(bt.agent_name)) { results.skipped++; continue; }
// Skip 0-video bots (keep 0-video humans)
if (bt.video_count === 0 && !bt.is_human) { results.skipped++; continue; }
upsertAgent(bottubeToAtlas(bt));
results.bottube++;
}
// Prefer the Atlas-specific legacy response, then fall back to the current
// public agents envelope. The bounded popular page avoids spawning an
// unmanageable number of texture-bearing meshes at boot.
let btAgents = [];
let bottubeError = null;
for (const endpoint of [
`${bottubeBase}/api/atlas/agents`,
`${bottubeBase}/api/agents?limit=100&sort=popular`,
]) {
try {
const resp = await fetch(endpoint);
if (!resp.ok) continue;
btAgents = normalizeBottubeAgents(await resp.json());
if (btAgents.length > 0) break;
} catch (e) {
bottubeError = e;
}
} catch (e) {
console.warn('[data] BoTTube API unavailable:', e.message);
}
if (btAgents.length === 0 && bottubeError) {
console.warn('[data] BoTTube API unavailable:', bottubeError.message);
}
for (const bt of btAgents) {
if (shouldSkip(bt.agent_name)) { results.skipped++; continue; }
// Skip 0-video bots (keep 0-video humans)
if (bt.video_count === 0 && !bt.is_human) { results.skipped++; continue; }
upsertAgent(bottubeToAtlas(bt));
results.bottube++;
}

// --- 2. Beacon relay agents ---
Expand Down Expand Up @@ -830,6 +848,41 @@ export function buildingCount(pop) {
return Math.min(Math.floor(pop / 3) + 1, 15);
}

export function normalizeAvatarUrl(value, baseUrl = 'https://rustchain.org/beacon/') {
if (typeof value !== 'string') return '';

const candidate = value.trim();
if (!candidate || candidate.length > 2048) return '';

try {
const url = new URL(candidate, baseUrl);
if (url.protocol !== 'https:' || url.username || url.password) return '';
return url.href;
} catch {
return '';
}
}

export function avatarTextureUrl(value, proxyBase = '/beacon/api/avatar/') {
const normalized = normalizeAvatarUrl(value);
if (!normalized) return '';

const url = new URL(normalized);
if (url.origin !== 'https://bottube.ai') return normalized;

let filename;
try {
filename = decodeURIComponent(url.pathname.replace(/^\/avatar\//, ''));
} catch {
return '';
}
const validFilename = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,126}\.(?:svg|png|jpe?g|webp)$/i;
if (!url.pathname.startsWith('/avatar/') || url.search || url.hash || !validFilename.test(filename)) {
return '';
}
return `${proxyBase}${encodeURIComponent(filename)}`;
}

export function seededRandom(seed) {
let s = seed;
return function () {
Expand Down
78 changes: 77 additions & 1 deletion tests/test_beacon_atlas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import time
import sys
import os
import pathlib
import subprocess

# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
Expand Down Expand Up @@ -141,7 +143,81 @@ def test_agent_city_assignment(self):

class TestBeaconAtlasVisualization(unittest.TestCase):
"""Test 3D visualization logic and data structures."""


def test_avatar_url_normalization(self):
"""Avatar textures accept bounded HTTPS URLs and reject unsafe schemes."""
data_module = (
pathlib.Path(__file__).resolve().parents[1] / "site" / "beacon" / "data.js"
).as_uri()
script = f"""
import {{
avatarTextureUrl, normalizeAvatarUrl, normalizeBottubeAgents,
}} from {json.dumps(data_module)};
const longUrl = `https://cdn.example/${{'x'.repeat(2050)}}`;
console.log(JSON.stringify({{
absolute: normalizeAvatarUrl(' https://cdn.example/avatar.png '),
relative: normalizeAvatarUrl('/avatars/agent.png'),
http: normalizeAvatarUrl('http://cdn.example/avatar.png'),
data: normalizeAvatarUrl('data:image/png;base64,AAAA'),
javascript: normalizeAvatarUrl('javascript:alert(1)'),
credentials: normalizeAvatarUrl('https://user:pass@cdn.example/avatar.png'),
blank: normalizeAvatarUrl(' '),
oversized: normalizeAvatarUrl(longUrl),
proxy: avatarTextureUrl('https://bottube.ai/avatar/sophia-elya.svg'),
proxyQuery: avatarTextureUrl('https://bottube.ai/avatar/sophia.svg?v=1'),
external: avatarTextureUrl('https://cdn.example/avatar.png'),
arrayEnvelope: normalizeBottubeAgents({{ agents: [{{ agent_name: 'a' }}] }}),
legacyArray: normalizeBottubeAgents([{{ agent_name: 'b' }}]),
malformedEnvelope: normalizeBottubeAgents({{ agents: {{}} }}),
}}));
"""
result = subprocess.run(
["node", "--input-type=module", "--eval", script],
check=True,
capture_output=True,
text=True,
)
normalized = json.loads(result.stdout)

self.assertEqual(normalized["absolute"], "https://cdn.example/avatar.png")
self.assertEqual(
normalized["relative"], "https://rustchain.org/avatars/agent.png"
)
for rejected in (
"http", "data", "javascript", "credentials", "blank", "oversized"
):
self.assertEqual(normalized[rejected], "", rejected)
self.assertEqual(
normalized["proxy"], "/beacon/api/avatar/sophia-elya.svg"
)
self.assertEqual(normalized["proxyQuery"], "")
self.assertEqual(normalized["external"], "https://cdn.example/avatar.png")
self.assertEqual(normalized["arrayEnvelope"], [{"agent_name": "a"}])
self.assertEqual(normalized["legacyArray"], [{"agent_name": "b"}])
self.assertEqual(normalized["malformedEnvelope"], [])

def test_avatar_sprite_renderer_contract(self):
"""Avatar sprites stay interactive and preserve a geometry fallback."""
source = (
pathlib.Path(__file__).resolve().parents[1]
/ "site"
/ "beacon"
/ "agents.js"
).read_text(encoding="utf-8")

required_fragments = (
"avatarTextureUrl(agent.avatar)",
"new THREE.TextureLoader()",
"avatarTextureLoader.setCrossOrigin('anonymous')",
"new THREE.SpriteMaterial({",
"sprite.visible = false",
"registerClickable(avatar)",
"registerHoverable(avatar)",
"material.map = null",
)
for fragment in required_fragments:
self.assertIn(fragment, source)

def test_bounty_position_calculation(self):
"""Test 3D positioning of bounty beacons."""
import math
Expand Down
Loading