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 }