OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / lib / server / index.js

697 lines · javascript ref:
#!/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)
  })
})