OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / hyper-social-web / lib / server.js

639 lines · javascript ref:
import http from 'bare-http1'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'

import { FeedStore } from './feed-store.js'
import { POSSEEngine } from './posse.js'
import { MastodonClient } from './clients/mastodon-client.js'
import { AtprotoClient } from './clients/atproto-client.js'
import { processMedia } from './media.js'
import { createAuth, extractAuth, requireAuth } from './auth.js'
import { signPost, verifyPost } from './post-signer.js'

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'application/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.png': 'image/png',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon'
}

function json (res, data, status = 200) {
  res.statusCode = status
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
  res.end(JSON.stringify(data))
}

function error (res, msg, status = 400) {
  json(res, { error: msg }, status)
}

async function bodyJson (req) {
  return new Promise((resolve, reject) => {
    let data = ''
    req.on('data', (chunk) => { data += chunk.toString() })
    req.on('end', () => {
      try { resolve(JSON.parse(data)) } catch { reject(new Error('Invalid JSON')) }
    })
    req.on('error', reject)
  })
}

async function parseMultipart (req) {
  const contentType = req.headers['content-type'] || ''
  const boundaryMatch = contentType.match(/boundary=(.+)/)
  if (!boundaryMatch) throw new Error('No multipart boundary')

  const boundary = boundaryMatch[1]

  return new Promise((resolve, reject) => {
    const chunks = []
    req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
    req.on('end', () => {
      const body = Buffer.concat(chunks)
      const fields = {}
      let file = null

      const boundaryBuf = Buffer.from('--' + boundary)
      const parts = splitBuffer(body, boundaryBuf)

      for (const part of parts) {
        const headerEnd = bufferIndexOf(part, Buffer.from('\r\n\r\n'))
        if (headerEnd === -1) continue

        const headerStr = part.slice(0, headerEnd).toString()
        const content = part.slice(headerEnd + 4)

        // Strip trailing \r\n
        const trimmed = content.length > 2 && content[content.length - 2] === 13 && content[content.length - 1] === 10
          ? content.slice(0, -2) : content

        const nameMatch = headerStr.match(/name="([^"]+)"/)
        const filenameMatch = headerStr.match(/filename="([^"]+)"/)
        const typeMatch = headerStr.match(/Content-Type:\s*(\S+)/i)

        if (!nameMatch) continue

        if (filenameMatch) {
          file = {
            fieldName: nameMatch[1],
            filename: filenameMatch[1],
            mimeType: typeMatch ? typeMatch[1] : 'application/octet-stream',
            buffer: trimmed
          }
        } else {
          fields[nameMatch[1]] = trimmed.toString()
        }
      }

      resolve({ fields, file })
    })
    req.on('error', reject)
  })
}

function splitBuffer (buf, delimiter) {
  const parts = []
  let start = 0
  while (true) {
    const idx = bufferIndexOf(buf, delimiter, start)
    if (idx === -1) {
      if (start < buf.length) parts.push(buf.slice(start))
      break
    }
    if (idx > start) parts.push(buf.slice(start, idx))
    start = idx + delimiter.length
  }
  return parts
}

function bufferIndexOf (buf, needle, start = 0) {
  for (let i = start; i <= buf.length - needle.length; i++) {
    let found = true
    for (let j = 0; j < needle.length; j++) {
      if (buf[i + j] !== needle[j]) { found = false; break }
    }
    if (found) return i
  }
  return -1
}

async function createServer (opts = {}) {
  const { port = 5050, prefix = '', storage } = opts

  const appDir = path.dirname(path.dirname(new URL(import.meta.url).pathname))
  const webDir = path.join(appDir, 'web')

  // Storage
  const storageDir = storage || path.join(os.homedir(), '.config', 'hyper-social-web')
  try { fs.mkdirSync(storageDir, { recursive: true }) } catch {}

  const store = new Corestore(path.join(storageDir, 'corestore'))
  await store.ready()

  const bee = new Hyperbee(store.get({ name: 'feed' }), {
    keyEncoding: 'utf-8',
    valueEncoding: 'json'
  })
  await bee.ready()

  const feedStore = new FeedStore(bee)
  const posse = new POSSEEngine(feedStore)

  // Load saved accounts
  const accountsBee = new Hyperbee(store.get({ name: 'accounts' }), {
    keyEncoding: 'utf-8',
    valueEncoding: 'json'
  })
  await accountsBee.ready()
  await loadAccounts(accountsBee, posse)

  // Identity / auth
  const { authManager, identityRegistry } = await createAuth(store)

  // HTTP server
  const server = http.createServer(async (req, res) => {
    let url = req.url

    // Strip prefix (Apache ProxyPass keeps the prefix in the path)
    if (prefix && url.startsWith(prefix)) {
      url = url.slice(prefix.length)
      if (!url || url[0] !== '/') url = '/' + url
    }

    // CORS for local dev
    res.setHeader('Access-Control-Allow-Origin', '*')
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Identity-PublicKey, X-Identity-Signature, X-Identity-Challenge')
    if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return }

    try {
      // --- API routes ---
      if (url.startsWith('/api/')) {
        await handleApi(url, req, res, { posse, feedStore, accountsBee, prefix, authManager, identityRegistry })
        return
      }

      // --- Static files ---
      await serveStatic(url, res, webDir)
    } catch (err) {
      if (err.status === 401) {
        error(res, err.message || 'Authentication required', 401)
      } else if (err.status === 403) {
        error(res, err.message || 'Forbidden', 403)
      } else if (err.status === 409) {
        error(res, err.message || 'Conflict', 409)
      } else if (err.status === 400) {
        error(res, err.message || 'Bad request', 400)
      } else {
        console.error('[server] Error:', err.message)
        error(res, 'Internal server error', 500)
      }
    }
  })

  server.listen(port, '0.0.0.0')
  return { server, store, posse, feedStore }
}

async function handleApi (url, req, res, ctx) {
  const { posse, feedStore, accountsBee, prefix, authManager, identityRegistry } = ctx
  const method = req.method

  // --- Auth endpoints (public) ---

  // POST /api/auth/challenge — get a challenge for signing
  if (url === '/api/auth/challenge' && method === 'POST') {
    const challenge = await authManager.generateChallenge()
    json(res, challenge)
    return
  }

  // GET /api/auth/me — get current user (if authenticated)
  if (url === '/api/auth/me' && method === 'GET') {
    const auth = await extractAuth(req, authManager)
    if (!auth) { json(res, { user: null }); return }

    const user = auth.user || await identityRegistry.getUser(auth.userId)
    json(res, {
      user: user ? {
        userId: user.userId,
        username: user.username,
        name: user.name,
        globalPermissions: user.globalPermissions || [],
        admin: !!(user.globalPermissions && user.globalPermissions.includes('admin'))
      } : null,
      publicKey: auth.identityId
    })
    return
  }

  // GET /api/admin/users — list all users (admin only)
  if (url === '/api/admin/users' && method === 'GET') {
    const auth = await requireAuth(req, authManager)
    const user = auth.user || await identityRegistry.getUser(auth.userId)
    if (!user || !user.globalPermissions || !user.globalPermissions.includes('admin')) {
      error(res, 'Admin access required', 403); return
    }

    const users = await identityRegistry.listUsers()
    json(res, {
      users: users.map(u => ({
        userId: u.userId,
        username: u.username,
        name: u.name,
        globalPermissions: u.globalPermissions || [],
        identities: u.identities || [],
        createdAt: u.createdAt
      }))
    })
    return
  }

  // POST /api/admin/promote — promote user to admin (admin only)
  if (url === '/api/admin/promote' && method === 'POST') {
    const auth = await requireAuth(req, authManager)
    const adminUser = auth.user || await identityRegistry.getUser(auth.userId)
    if (!adminUser || !adminUser.globalPermissions || !adminUser.globalPermissions.includes('admin')) {
      error(res, 'Admin access required', 403); return
    }

    const body = await bodyJson(req)
    if (!body.userId) { error(res, 'userId required'); return }

    await identityRegistry.upgradePermissions(body.userId, '*', ['read', 'write', 'admin'])
    const updated = await identityRegistry.getUser(body.userId)
    json(res, { user: { userId: updated.userId, username: updated.username, globalPermissions: updated.globalPermissions } })
    return
  }

  // POST /api/auth/register — explicit registration (creates user + identity)
  if (url === '/api/auth/register' && method === 'POST') {
    const body = await bodyJson(req)
    if (!body.publicKey || body.publicKey.length !== 44) {
      error(res, 'publicKey required (44-char base64)'); return
    }

    // Check if already registered
    const existing = await identityRegistry.getIdentityByPublicKey(body.publicKey)
    if (existing) {
      // Already registered — just return the user
      const user = await identityRegistry.getUserByIdentity(existing.identityId)
      json(res, { user: user ? { userId: user.userId, username: user.username, admin: !!(user.globalPermissions && user.globalPermissions.includes('admin')) } : null, existing: true })
      return
    }

    // Register new user
    const result = await identityRegistry.autoRegister({
      identityId: body.publicKey,
      method: 'web-identity',
      credentials: { publicKey: body.publicKey },
      name: body.username || 'Anonymous'
    })

    json(res, {
      user: {
        userId: result.user.userId,
        username: result.user.username,
        admin: !!(result.user.globalPermissions && result.user.globalPermissions.includes('admin'))
      },
      existing: false
    })
    return
  }

  // DELETE /api/admin/users/:userId — delete user (admin only)
  if (url.startsWith('/api/admin/users/') && method === 'DELETE') {
    const auth = await requireAuth(req, authManager)
    const adminUser = auth.user || await identityRegistry.getUser(auth.userId)
    if (!adminUser || !adminUser.globalPermissions || !adminUser.globalPermissions.includes('admin')) {
      error(res, 'Admin access required', 403); return
    }

    const userId = decodeURIComponent(url.slice('/api/admin/users/'.length))
    if (userId === auth.userId) { error(res, 'Cannot delete yourself', 400); return }

    await identityRegistry.deleteUserCascade(userId)
    json(res, { ok: true })
    return
  }

  // POST /api/auth/username — set username (requires auth)
  if (url === '/api/auth/username' && method === 'POST') {
    const auth = await requireAuth(req, authManager)
    const body = await bodyJson(req)
    if (!body.username) { error(res, 'username required'); return }

    const user = await identityRegistry.setUsername(auth.userId, body.username)
    json(res, { user: { userId: user.userId, username: user.username, name: user.name } })
    return
  }

  // GET /api/user/profile — full profile with identities (requires auth)
  if (url === '/api/user/profile' && method === 'GET') {
    const auth = await requireAuth(req, authManager)
    const full = await identityRegistry.getUserWithIdentities(auth.userId)
    if (!full) { json(res, { user: null }); return }

    json(res, {
      user: {
        userId: full.user.userId,
        username: full.user.username,
        name: full.user.name,
        theme: full.user.theme || 'dark',
        globalPermissions: full.user.globalPermissions || [],
        admin: !!(full.user.globalPermissions && full.user.globalPermissions.includes('admin')),
        createdAt: full.user.createdAt
      },
      identities: full.identities.map(id => ({
        identityId: id.identityId,
        method: id.method,
        publicKey: id.publicKey,
        createdAt: id.createdAt
      })),
      publicKey: auth.identityId
    })
    return
  }

  // PATCH /api/auth/profile — update name/theme (requires auth)
  if (url === '/api/auth/profile' && method === 'PATCH') {
    const auth = await requireAuth(req, authManager)
    const body = await bodyJson(req)
    const updated = await identityRegistry.updateUserProfile(auth.userId, body)
    json(res, {
      user: {
        userId: updated.userId,
        username: updated.username,
        name: updated.name,
        theme: updated.theme || 'dark'
      }
    })
    return
  }

  // POST /api/auth/import-keypair — link a new keypair to current user (requires auth)
  if (url === '/api/auth/import-keypair' && method === 'POST') {
    const auth = await requireAuth(req, authManager)
    const body = await bodyJson(req)
    if (!body.publicKey) { error(res, 'publicKey required'); return }

    const identity = await identityRegistry.importIdentity(auth.userId, body.publicKey, body.method || 'web-identity')
    json(res, { identity: { identityId: identity.identityId, method: identity.method, publicKey: identity.publicKey } })
    return
  }

  // POST /api/merge/initiate — start account merge (requires auth)
  if (url === '/api/merge/initiate' && method === 'POST') {
    const auth = await requireAuth(req, authManager)
    const body = await bodyJson(req)
    if (!body.targetPublicKey) { error(res, 'targetPublicKey required'); return }

    const merge = await authManager.initiateMerge(auth.userId, body.targetPublicKey)
    json(res, merge)
    return
  }

  // POST /api/merge/:mergeId/confirm — confirm merge with target signature
  if (url.match(/^\/api\/merge\/[^/]+\/confirm$/) && method === 'POST') {
    const mergeId = url.split('/')[3]
    const body = await bodyJson(req)
    if (!body.signature) { error(res, 'signature required'); return }

    const result = await authManager.confirmMerge(mergeId, body.signature)
    json(res, result)
    return
  }

  // GET /api/merge/:mergeId — get merge status
  if (url.match(/^\/api\/merge\/[^/]+$/) && method === 'GET' && !url.includes('/confirm')) {
    const mergeId = url.split('/')[3]
    const merge = await authManager.getMerge(mergeId)
    if (!merge) { error(res, 'Merge not found', 404); return }
    json(res, merge)
    return
  }

  // GET /api/users — public user directory (no auth)
  if (url === '/api/users' && method === 'GET') {
    const users = await identityRegistry.listPublicUsers()
    json(res, { users })
    return
  }

  // --- Write endpoints (require auth) ---

  // POST /api/post — POSSE publish (text only, JSON body)
  if (url === '/api/post' && method === 'POST') {
    const auth = await requireAuth(req, authManager)
    const body = await bodyJson(req)
    const result = await posse.publish(body.text, body.targets || [], body)

    // Sign the post content
    const signed = signPost(body.text, { publicKey: Buffer.from(auth.identityId || '', 'base64'), secretKey: Buffer.alloc(64) })
    result.anproto = body.anproto || null
    result.authorPublicKey = auth.identityId || null

    json(res, result)
    return
  }

  // POST /api/post-media — POSSE publish with media (multipart)
  if (url === '/api/post-media' && method === 'POST') {
    await requireAuth(req, authManager)
    const { fields, file } = await parseMultipart(req)

    if (!file) { error(res, 'No file uploaded'); return }

    const text = fields.text || ''
    const targets = fields.targets ? JSON.parse(fields.targets) : []
    const altText = fields.altText || ''

    if (!text.trim()) { error(res, 'Text is required'); return }
    if (targets.length === 0) { error(res, 'Select at least one platform'); return }

    // Process media (resize, compress, strip metadata)
    const processed = await processMedia(file.buffer, file.filename, file.mimeType)

    const result = await posse.publish(text, targets, {
      media: processed,
      altText
    })

    json(res, result)
    return
  }

  // GET /api/timeline — unified timeline
  if (url.startsWith('/api/timeline')) {
    const params = new URL('http://x' + url).searchParams
    const source = params.get('source') || undefined
    const limit = Number(params.get('limit')) || 50
    const before = params.get('before') ? Number(params.get('before')) : undefined

    const items = await feedStore.timeline({ source, limit, before })
    json(res, { items })
    return
  }

  // GET /api/accounts — list connected accounts
  if (url === '/api/accounts' && method === 'GET') {
    const accounts = []
    for await (const node of accountsBee.createReadStream({ gte: 'account/', lte: 'account/~' })) {
      if (node.value) {
        const safe = { ...node.value }
        delete safe.token
        delete safe.password
        accounts.push(safe)
      }
    }
    json(res, { accounts })
    return
  }

  // POST /api/accounts/connect — connect a new account (requires auth)
  if (url === '/api/accounts/connect' && method === 'POST') {
    await requireAuth(req, authManager)
    const body = await bodyJson(req)
    const { platform, instance, handle, token, password } = body

    if (!platform) { error(res, 'platform required'); return }

    // Strip @ prefix from handles (Bluesky rejects it)
    const cleanHandle = handle ? handle.replace(/^@/, '') : handle

    const id = `${platform}:${cleanHandle || instance || 'default'}`
    const account = { id, platform, instance, handle: cleanHandle, token, password, connectedAt: Date.now() }

    await accountsBee.put(`account/${id}`, account)

    // Register client with POSSE engine
    try {
      await connectAccount(account, posse)
    } catch (err) {
      // Remove failed account from storage
      await accountsBee.del(`account/${id}`)
      error(res, err.message || 'Failed to connect account')
      return
    }

    const safe = { ...account }
    delete safe.token
    delete safe.password
    json(res, { account: safe })
    return
  }

  // DELETE /api/accounts/:id (requires auth)
  if (url.startsWith('/api/accounts/') && method === 'DELETE') {
    await requireAuth(req, authManager)
    const id = decodeURIComponent(url.slice('/api/accounts/'.length))
    await accountsBee.del(`account/${id}`)
    posse.removeClient(id)
    json(res, { ok: true })
    return
  }

  // GET /api/notifications
  if (url === '/api/notifications' && method === 'GET') {
    const notifications = await posse.notifications()
    json(res, { notifications })
    return
  }

  // GET /api/feed/refresh — pull latest from all platforms
  if (url === '/api/feed/refresh' && method === 'GET') {
    const count = await posse.refresh()
    json(res, { refreshed: count })
    return
  }

  // POST /api/feed/repull — clear feed and re-pull (to pick up avatars etc)
  if (url === '/api/feed/repull' && method === 'POST') {
    // Delete all timeline items
    let deleted = 0
    for await (const node of feedStore.bee.createReadStream({ gte: 'item/', lte: 'item/~' })) {
      if (node.value?.id) {
        const item = node.value
        const batch = feedStore.bee.batch()
        await batch.del(`item/${item.id}`)
        await batch.del(`timeline/${feedStore._padTimestamp(item.timestamp)}/${item.id}`)
        await batch.del(`source/${item.source}/${feedStore._padTimestamp(item.timestamp)}/${item.id}`)
        await batch.del(`author/${item.authorId}/${feedStore._padTimestamp(item.timestamp)}/${item.id}`)
        await batch.flush()
        deleted++
      }
    }
    // Re-pull
    const count = await posse.refresh()
    json(res, { deleted, refreshed: count })
    return
  }

  error(res, 'Not found', 404)
}

async function serveStatic (url, res, webDir) {
  let filePath = url === '/' ? '/index.html' : url

  // SPA fallback — serve index.html for non-file paths
  const ext = path.extname(filePath)
  if (!ext) filePath = '/index.html'

  const fullPath = path.join(webDir, filePath)

  try {
    const content = fs.readFileSync(fullPath)
    const mime = MIME[path.extname(fullPath)] || 'application/octet-stream'
    res.setHeader('Content-Type', mime)
    res.end(content)
  } catch {
    // SPA fallback for any path
    try {
      const index = fs.readFileSync(path.join(webDir, 'index.html'))
      res.setHeader('Content-Type', 'text/html; charset=utf-8')
      res.end(index)
    } catch {
      res.statusCode = 404
      res.end('Not found')
    }
  }
}

async function loadAccounts (accountsBee, posse) {
  for await (const node of accountsBee.createReadStream({ gte: 'account/', lte: 'account/~' })) {
    if (node.value) {
      try {
        await connectAccount(node.value, posse)
      } catch (err) {
        console.error(`[server] Failed to connect ${node.value.id}:`, err.message)
      }
    }
  }
}

async function connectAccount (account, posse) {
  if (account.platform === 'mastodon') {
    const client = new MastodonClient({
      instance: account.instance,
      token: account.token
    })
    posse.addClient(account.id, 'mastodon', client)
  } else if (account.platform === 'atproto') {
    const client = new AtprotoClient({
      service: account.instance || 'https://bsky.social',
      identifier: account.handle,
      password: account.password
    })
    await client.login()
    posse.addClient(account.id, 'atproto', client)
  }
}

export { createServer }