From 1d971a389fbe1b42738f4ee302123951a7811c08 Mon Sep 17 00:00:00 2001 From: SageHaven Date: Thu, 13 Aug 2026 18:34:06 +0100 Subject: [PATCH] Bounty #1524: feat(beacon): render agent avatar sprites --- node/beacon_api.py | 73 +++++++++++++++++++++++++++- site/beacon/agents.js | 54 ++++++++++++++++++++- site/beacon/data.js | 79 ++++++++++++++++++++++++++----- tests/test_beacon_atlas.py | 78 +++++++++++++++++++++++++++++- tests/test_beacon_avatar_proxy.py | 63 ++++++++++++++++++++++++ 5 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 tests/test_beacon_avatar_proxy.py diff --git a/node/beacon_api.py b/node/beacon_api.py index 53c1fb21d..0b9ad4b75 100644 --- a/node/beacon_api.py +++ b/node/beacon_api.py @@ -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 @@ -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 @@ -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/', 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 # ============================================================ diff --git a/site/beacon/agents.js b/site/beacon/agents.js index 7b5d49aab..5b24a3c9c 100644 --- a/site/beacon/agents.js +++ b/site/beacon/agents.js @@ -5,7 +5,7 @@ import * as THREE from 'three'; import { AGENTS, GRADE_COLORS, agentCity, cityPosition, seededRandom, - getProviderColor, + getProviderColor, avatarTextureUrl, } from './data.js'; import { getScene, registerClickable, registerHoverable, onAnimate, @@ -13,6 +13,8 @@ import { 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); @@ -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) @@ -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 @@ -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) { @@ -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'); diff --git a/site/beacon/data.js b/site/beacon/data.js index e8da82227..a9625d9f0 100644 --- a/site/beacon/data.js +++ b/site/beacon/data.js @@ -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); @@ -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 --- @@ -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 () { diff --git a/tests/test_beacon_atlas.py b/tests/test_beacon_atlas.py index 034ae1e53..ae37115a0 100644 --- a/tests/test_beacon_atlas.py +++ b/tests/test_beacon_atlas.py @@ -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__)))) @@ -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 diff --git a/tests/test_beacon_avatar_proxy.py b/tests/test_beacon_avatar_proxy.py new file mode 100644 index 000000000..1d9f3e3c3 --- /dev/null +++ b/tests/test_beacon_avatar_proxy.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: MIT +import io +import unittest +import urllib.error +from unittest import mock + +from flask import Flask + +from node import beacon_api as beacon_module + + +def _client(): + app = Flask(__name__) + app.register_blueprint(beacon_module.beacon_api, url_prefix="/beacon") + app.config.update(TESTING=True) + return app.test_client() + + +class TestBeaconAvatarProxy(unittest.TestCase): + def test_returns_bounded_image_with_hardening_headers(self): + with mock.patch.object( + beacon_module, + "_fetch_bottube_avatar", + return_value=(b"", "image/svg+xml"), + ): + response = _client().get("/beacon/api/avatar/sophia-elya.svg") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, b"") + self.assertEqual(response.content_type, "image/svg+xml") + self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*") + self.assertEqual(response.headers["Cache-Control"], "public, max-age=86400") + self.assertEqual(response.headers["X-Content-Type-Options"], "nosniff") + self.assertIn( + "script-src 'none'", response.headers["Content-Security-Policy"] + ) + + def test_rejects_paths_that_could_escape_fixed_origin(self): + with mock.patch.object(beacon_module, "_fetch_bottube_avatar") as fetch: + for filename in ("not-an-image.txt", "avatar.svg%3Fraw=1", ".svg"): + response = _client().get(f"/beacon/api/avatar/{filename}") + self.assertEqual(response.status_code, 400) + fetch.assert_not_called() + + def test_maps_upstream_not_found_without_leaking_details(self): + missing = urllib.error.HTTPError( + "https://bottube.ai/avatar/missing.svg", + 404, + "Not Found", + {}, + io.BytesIO(), + ) + with mock.patch.object( + beacon_module, "_fetch_bottube_avatar", side_effect=missing + ): + response = _client().get("/beacon/api/avatar/missing.svg") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.get_json(), {"error": "avatar unavailable"}) + + +if __name__ == "__main__": + unittest.main()