#!/usr/bin/env node
/**
* Wirecore Web Server
*
* Local HTTP server that provides a browser interface for Wirecore
* - Serves static HTML/CSS/JS
* - REST API for Wirecore operations
* - WebSocket for real-time updates
*/
import express from 'express'
import { WebSocketServer } from 'ws'
import { createServer } from 'http'
import rateLimit from 'express-rate-limit'
import cors from 'cors'
import { Wirecore } from '../core/wirecore.js'
import { ANProto } from '../core/anproto.js'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { readdir, readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const app = express()
const server = createServer(app)
const wss = new WebSocketServer({ server })
// Multi-user feed management
const userInstances = new Map() // publicKey → Wirecore instance
const USER_FEEDS_DIR = './data/users/wirecore-web-users'
// Sanitize public key for safe filesystem usage
function sanitizePublicKey(key) {
if (!key || typeof key !== 'string' || key.length < 16) {
throw new Error('Invalid public key')
}
// Validate base64 format (44 chars, ending in =)
if (key.length !== 44 || !/^[A-Za-z0-9+/]{43}=$/.test(key)) {
throw new Error('Invalid public key format')
}
// Replace filesystem-unsafe characters (/, \, :, etc.) with underscore
return key.substring(0, 16).replace(/[/\\:*?"<>|]/g, '_')
}
// Sanitize user-supplied name (XSS prevention)
function sanitizeName(name) {
if (!name || typeof name !== 'string') return 'Anonymous'
// Remove HTML tags, limit length
const clean = name
.replace(/<[^>]*>/g, '') // Strip HTML tags
.replace(/[^\w\s-]/g, '') // Only allow alphanumeric, spaces, hyphens
.substring(0, 100)
.trim()
return clean || 'Anonymous'
}
// Sanitize text content (prevent script injection)
function sanitizeText(text) {
if (!text || typeof text !== 'string') return ''
// Remove null bytes and control characters
return text
.replace(/\x00/g, '') // Null bytes
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') // Control chars
.substring(0, 10000) // Max length 10KB
.trim()
}
// Get or create Wirecore instance for a user
async function getUserInstance(publicKey) {
if (userInstances.has(publicKey)) {
return userInstances.get(publicKey)
}
// Create new instance for this user
const sanitizedKey = sanitizePublicKey(publicKey)
const storagePath = join(USER_FEEDS_DIR, sanitizedKey)
const wc = new Wirecore(storagePath)
// Initialize with custom keypair (derived from publicKey)
// For now, let server manage the feed but track by publicKey
await wc.init()
// Save metadata mapping browser publicKey to this feed directory
const metadataPath = join(storagePath, 'metadata.json')
await writeFile(metadataPath, JSON.stringify({
browserPublicKey: publicKey,
feedKey: wc.getFeedKey(),
created: new Date().toISOString()
}, null, 2))
// Listen for events from this user's instance
wc.on('post', (post) => {
const author = publicKey // Use the browser's publicKey as author
broadcast({ type: 'post', data: { ...post, author } })
})
wc.on('follow', ({ publicKey: followedKey, name }) => {
broadcast({ type: 'follow', data: { publicKey: followedKey, name, author: publicKey } })
})
userInstances.set(publicKey, wc)
console.log('Created instance for user:', publicKey.substring(0, 8) + '...')
return wc
}
// Load all existing user feeds on startup
async function loadExistingFeeds() {
if (!existsSync(USER_FEEDS_DIR)) {
console.log('No existing user feeds directory')
return
}
const dirs = await readdir(USER_FEEDS_DIR)
console.log(`Found ${dirs.length} user feed directories`)
// Load all users in parallel for fast startup
const loadPromises = dirs.map(async (dir) => {
try {
const storagePath = join(USER_FEEDS_DIR, dir)
const metadataPath = join(storagePath, 'metadata.json')
if (!existsSync(metadataPath)) {
console.log(`⚠️ Skipping ${dir} - no metadata.json`)
return null
}
const metadata = JSON.parse(await readFile(metadataPath, 'utf8'))
const publicKey = metadata.browserPublicKey
if (!publicKey) {
console.log(`⚠️ Skipping ${dir} - no browserPublicKey in metadata`)
return null
}
// Load Wirecore instance for this user
const wc = new Wirecore(storagePath)
await wc.init()
// Load profile if it exists (important for name display!)
const profilePath = join(storagePath, 'profile.json')
if (existsSync(profilePath)) {
try {
const profile = JSON.parse(await readFile(profilePath, 'utf8'))
if (profile.name) {
wc.name = profile.name
console.log(` Profile: ${profile.name}`)
}
} catch (err) {
console.log(` ⚠️ Could not load profile:`, err.message)
}
}
// Listen for events
wc.on('post', (post) => {
broadcast({ type: 'post', data: { ...post, author: publicKey } })
})
wc.on('follow', ({ publicKey: followedKey, name }) => {
broadcast({ type: 'follow', data: { publicKey: followedKey, name, author: publicKey } })
})
userInstances.set(publicKey, wc)
console.log(`✓ Loaded feed for user: ${publicKey.substring(0, 8)}... (${wc.myFeed.length} posts)`)
return { publicKey, wc }
} catch (err) {
console.error(`Error loading feed ${dir}:`, err.message)
return null
}
})
// Wait for all users to load in parallel
await Promise.all(loadPromises)
console.log(`Loaded ${userInstances.size} user feeds in parallel`)
}
// WebSocket clients
const clients = new Set()
// Broadcast to all connected clients
function broadcast(data) {
const message = JSON.stringify(data)
clients.forEach(client => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(message)
}
})
}
console.log('Wirecore multi-user server initialized')
// WebSocket connection handler
wss.on('connection', (ws) => {
clients.add(ws)
console.log('WebSocket client connected. Total clients:', clients.size)
ws.on('close', () => {
clients.delete(ws)
console.log('WebSocket client disconnected. Total clients:', clients.size)
})
ws.on('error', (err) => {
console.error('WebSocket error:', err)
clients.delete(ws)
})
})
// Security Middleware
// Base Path Handling - Strip /wirecore prefix when behind reverse proxy
app.use((req, res, next) => {
if (req.path.startsWith('/wirecore')) {
req.url = req.url.replace(/^\/wirecore/, '') || '/'
}
next()
})
// 1. CORS Configuration
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000', 'https://7rnx.net'],
credentials: true,
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'x-wirecore-publickey']
}))
// 2. Request Size Limits (prevent DoS via large payloads)
app.use(express.json({ limit: '100kb' }))
// 3. Rate Limiting - General API
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false
})
// 4. Rate Limiting - Posting (stricter)
const postLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // 10 posts per minute
message: 'Posting too fast, please slow down',
standardHeaders: true,
legacyHeaders: false
})
// 5. Rate Limiting - Profile Updates
const profileLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 profile updates per minute
message: 'Too many profile updates, please wait',
standardHeaders: true,
legacyHeaders: false
})
// Apply rate limiting to API routes
app.use('/api', apiLimiter)
// Static files
app.use(express.static(join(__dirname, '../../public')))
// API Routes
// Get user info (browser-side identity - returns client's own identity)
app.get('/api/whoami', async (req, res) => {
try {
const publicKey = req.headers['x-wirecore-publickey']
if (!publicKey) {
return res.status(401).json({ error: 'Public key required' })
}
res.json({
publicKey,
profile: { name: 'Browser User', bio: 'Managed client-side' }
})
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Get timeline (aggregate from all user instances)
app.get('/api/timeline', async (req, res) => {
try {
const userPublicKey = req.headers['x-wirecore-publickey']
const limit = parseInt(req.query.limit) || 50
const offset = parseInt(req.query.offset) || 0
const types = req.query.types ? req.query.types.split(',') : ['post', 'reply', 'boost']
if (!userPublicKey) {
return res.status(401).json({ error: 'Public key required' })
}
// Get the user's instance
const userWc = await getUserInstance(userPublicKey)
// userWc.getTimeline() already aggregates posts from:
// - Own feed
// - All followed users' feeds (replicated locally)
// No need to iterate through userInstances - followed users don't have instances here
const allPosts = await userWc.getTimeline({ limit: 1000, types })
// Apply pagination
const total = allPosts.length
const paginatedPosts = allPosts.slice(offset, offset + limit)
const hasMore = (offset + limit) < total
// Return posts with pagination metadata
res.json({
posts: paginatedPosts,
pagination: {
total,
offset,
limit,
hasMore
}
})
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Post a message
app.post('/api/post', postLimiter, async (req, res) => {
try {
const publicKey = req.headers['x-wirecore-publickey']
const { signature, content } = req.body
if (!publicKey) {
return res.status(401).json({ error: 'Public key required' })
}
if (!signature || !content) {
return res.status(400).json({ error: 'Signature and content required' })
}
// Verify signature using ANProto
try {
const message = {
anproto: signature,
content: content
}
const verified = ANProto.verifyMessage(message)
// Ensure the signature's public key matches the claimed public key
if (verified.author !== publicKey) {
return res.status(403).json({
error: 'Signature verification failed: public key mismatch',
expected: publicKey,
actual: verified.author
})
}
console.log(`✓ Verified post from ${publicKey.substring(0, 8)}...`)
} catch (verifyError) {
console.error('Signature verification failed:', verifyError.message)
return res.status(403).json({
error: 'Invalid signature',
details: verifyError.message
})
}
// Get or create user instance
const wc = await getUserInstance(publicKey)
// Post to their feed
const post = await wc.post(content)
res.json({ ...post, author: publicKey })
} catch (err) {
console.error('Post error:', err)
res.status(500).json({ error: err.message })
}
})
// Follow a user
app.post('/api/follow', async (req, res) => {
try {
const userPublicKey = req.headers['x-wirecore-publickey']
const { publicKey, feedKey: clientFeedKey, name } = req.body
if (!userPublicKey) {
return res.status(401).json({ error: 'User public key required' })
}
if (!publicKey) {
return res.status(400).json({ error: 'Public key is required' })
}
// Use provided feedKey or discover it
let feedKey = clientFeedKey
if (!feedKey) {
// Try to discover the user's feed key
const targetWc = userInstances.get(publicKey)
if (targetWc) {
// User is online
feedKey = targetWc.getFeedKey()
} else {
// Check metadata on disk
const storagePath = join(USER_FEEDS_DIR, publicKey.substring(0, 16))
const metadataPath = join(storagePath, 'metadata.json')
if (!existsSync(metadataPath)) {
return res.status(404).json({ error: 'User not found - please provide feedKey' })
}
const metadata = JSON.parse(await readFile(metadataPath, 'utf8'))
if (metadata.browserPublicKey !== publicKey) {
return res.status(404).json({ error: 'User not found' })
}
feedKey = metadata.feedKey
}
}
if (!feedKey) {
return res.status(400).json({ error: 'Feed key is required for external users' })
}
const wc = await getUserInstance(userPublicKey)
await wc.follow(publicKey, feedKey, sanitizeName(name))
res.json({ success: true })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Get following list
app.get('/api/following', async (req, res) => {
try {
const userPublicKey = req.headers['x-wirecore-publickey']
if (!userPublicKey) {
return res.status(401).json({ error: 'User public key required' })
}
const wc = await getUserInstance(userPublicKey)
const following = wc.getFollowing()
res.json({ following })
} catch (err) {
console.error('Get following error:', err)
res.status(500).json({ error: err.message })
}
})
// Unfollow a user
app.post('/api/unfollow', async (req, res) => {
try {
const userPublicKey = req.headers['x-wirecore-publickey']
const { publicKey } = req.body
if (!userPublicKey) {
return res.status(401).json({ error: 'User public key required' })
}
if (!publicKey) {
return res.status(400).json({ error: 'Public key is required' })
}
const wc = await getUserInstance(userPublicKey)
await wc.unfollow(publicKey)
res.json({ success: true })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Like a post
app.post('/api/like', async (req, res) => {
try {
const userPublicKey = req.headers['x-wirecore-publickey']
const { author, sequence } = req.body
if (!userPublicKey) {
return res.status(401).json({ error: 'User public key required' })
}
if (!author || sequence === undefined) {
return res.status(400).json({ error: 'Author and sequence are required' })
}
const wc = await getUserInstance(userPublicKey)
const like = await wc.like(author, parseInt(sequence))
res.json(like)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Boost a post
app.post('/api/boost', async (req, res) => {
try {
const userPublicKey = req.headers['x-wirecore-publickey']
const { author, sequence } = req.body
if (!userPublicKey) {
return res.status(401).json({ error: 'User public key required' })
}
if (!author || sequence === undefined) {
return res.status(400).json({ error: 'Author and sequence are required' })
}
const wc = await getUserInstance(userPublicKey)
const boost = await wc.boost(author, parseInt(sequence))
res.json(boost)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Reply to a post
app.post('/api/reply', async (req, res) => {
try {
const userPublicKey = req.headers['x-wirecore-publickey']
const { author, sequence, text } = req.body
if (!userPublicKey) {
return res.status(401).json({ error: 'User public key required' })
}
if (!author || sequence === undefined || !text) {
return res.status(400).json({ error: 'Author, sequence, and text are required' })
}
const wc = await getUserInstance(userPublicKey)
const reply = await wc.reply(author, parseInt(sequence), text)
res.json(reply)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Get user profile (browser-managed)
app.get('/api/profile/:publicKey', async (req, res) => {
try {
// Profiles are now managed client-side in IndexedDB
res.json({ name: 'Browser User', bio: 'Managed client-side' })
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Update profile
app.post('/api/profile', profileLimiter, async (req, res) => {
try {
const publicKey = req.headers['x-wirecore-publickey']
const { name, bio, image } = req.body
if (!publicKey) {
return res.status(401).json({ error: 'Public key required' })
}
// Sanitize user input (XSS prevention)
const sanitizedName = sanitizeName(name)
// Get or create user instance
const wc = await getUserInstance(publicKey)
// Update server-side profile (used in YAML metadata)
await wc.updateProfile({ name: sanitizedName, image })
console.log(`✓ Updated profile for ${publicKey.substring(0, 8)}...: name=${sanitizedName}`)
res.json({ success: true, name: sanitizedName, image })
} catch (err) {
console.error('Profile update error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// Discover a user's feed key by their public key
app.get('/api/discover/:publicKey', async (req, res) => {
try {
const { publicKey } = req.params
// Check if this user has an instance
const wc = userInstances.get(publicKey)
if (wc) {
// User is currently loaded
return res.json({
publicKey,
feedKey: wc.getFeedKey(),
online: true
})
}
// Check if user has metadata on disk
const storagePath = join(USER_FEEDS_DIR, publicKey.substring(0, 16))
const metadataPath = join(storagePath, 'metadata.json')
if (!existsSync(metadataPath)) {
return res.status(404).json({ error: 'User not found' })
}
const metadata = JSON.parse(await readFile(metadataPath, 'utf8'))
if (metadata.browserPublicKey !== publicKey) {
return res.status(404).json({ error: 'User not found' })
}
res.json({
publicKey,
feedKey: metadata.feedKey,
online: false
})
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Get follows list
app.get('/api/follows', async (req, res) => {
try {
const follows = []
for (const [publicKey, { name }] of wc.follows) {
follows.push({ publicKey, name })
}
res.json(follows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
wirecore: 'running',
connections: clients.size,
users: userInstances.size
})
})
// Global Error Handler (must be last middleware)
app.use((err, req, res, next) => {
console.error('API Error:', err)
// Don't leak error details in production
const isDev = process.env.NODE_ENV !== 'production'
res.status(err.status || 500).json({
error: isDev ? err.message : 'Internal server error'
})
})
// Start server
const PORT = process.env.PORT || 3000
async function startServer() {
// Load all existing user feeds before starting server
await loadExistingFeeds()
server.listen(PORT, '0.0.0.0', () => {
console.log(`\n🚀 Wirecore Web Server running!`)
console.log(`\n📍 Local: http://localhost:${PORT}`)
console.log(`📍 Network: http://10.22.0.1:${PORT}`)
console.log(`🔌 WebSocket: ws://localhost:${PORT}`)
console.log(`\n✨ Open in your browser and start posting!\n`)
})
}
startServer().catch(err => {
console.error('Failed to start server:', err)
process.exit(1)
})
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\n\nShutting down gracefully...')
// Close all user instances
for (const [publicKey, wc] of userInstances.entries()) {
try {
await wc.close()
} catch (err) {
console.error(`Error closing feed for ${publicKey.substring(0, 8)}:`, err.message)
}
}
server.close(() => {
console.log('Server closed')
process.exit(0)
})
})