/**
* Site Manager
*
* Creates, edits, and publishes personal websites as writable Hyperdrives.
* Users own the keypair — their site is theirs forever.
*/
const Hyperdrive = require('hyperdrive')
const b4a = require('b4a')
class SiteManager {
constructor (store, swarm) {
this.store = store
this.swarm = swarm
this.sites = new Map() // siteId → { drive, name, published, createdAt }
}
/**
* Create a new site (writable Hyperdrive)
*/
async createSite (name) {
// Validate site name
const validatedName = this._validateSiteName(name)
// Use a unique per-drive namespace to avoid Corestore contention
// when other drives are under active replication (HiveRelay pins).
// Same pattern p2p-hiverelay uses in its publish() flow.
const ns = this.store.namespace('site-' + Date.now() + '-' + Math.random().toString(36).slice(2))
const drive = new Hyperdrive(ns)
await drive.ready()
const keyHex = b4a.toString(drive.key, 'hex')
const siteId = keyHex.slice(0, 16)
// Write a default index.html
await drive.put('/index.html', Buffer.from(this._defaultHtml(validatedName)))
await drive.put('/style.css', Buffer.from(this._defaultCss()))
this.sites.set(siteId, {
drive,
keyHex,
name: validatedName,
published: false,
createdAt: Date.now()
})
return { siteId, keyHex, name: validatedName }
}
/**
* Update files on a site
*/
async updateSite (siteId, files) {
const site = this.sites.get(siteId)
if (!site) throw new Error('Site not found: ' + siteId)
// Validate files array
if (!Array.isArray(files)) {
throw new Error('Files must be an array')
}
const MAX_FILES = 100
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
if (files.length > MAX_FILES) {
throw new Error(`Too many files. Maximum is ${MAX_FILES}`)
}
for (const file of files) {
// Validate file path
if (!this._validateFilePath(file.path)) {
throw new Error(`Invalid file path: ${file.path}`)
}
// Validate content
if (typeof file.content !== 'string') {
throw new Error(`Invalid content for ${file.path}`)
}
if (file.content.length > MAX_FILE_SIZE) {
throw new Error(`File too large: ${file.path}`)
}
await site.drive.put(file.path, Buffer.from(file.content))
}
return { updated: files.length }
}
/**
* Set the site's icon from a base64 image data URL — writes /icon.<ext> into
* the drive (peers replicate the new block; an already-seeded site needs no
* re-publish). Replaces any prior icon.* so resolution is unambiguous. The
* browser fetches it via CMD_GET_APP_ICON's well-known-path resolver.
*/
async setIcon (siteId, dataUrl) {
const site = this.sites.get(siteId)
if (!site) throw new Error('Site not found: ' + siteId)
const m = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i.exec(String(dataUrl || '').trim())
if (!m) throw new Error('Icon must be a base64 image data URL')
const mime = m[1].toLowerCase()
const ext = mime === 'image/svg+xml' ? 'svg'
: mime === 'image/png' ? 'png'
: mime === 'image/jpeg' ? 'jpg'
: mime === 'image/webp' ? 'webp'
: mime === 'image/x-icon' ? 'ico' : null
if (!ext) throw new Error('Unsupported icon type: ' + mime + ' (use SVG, PNG, JPEG, or WebP)')
const buf = Buffer.from(m[2], 'base64')
if (!buf.length) throw new Error('Empty icon')
if (buf.length > 512 * 1024) throw new Error('Icon too large (max 512KB)')
for (const e of ['svg', 'png', 'jpg', 'jpeg', 'webp', 'ico']) {
if (e !== ext) await site.drive.del('/icon.' + e).catch(() => {})
}
const path = '/icon.' + ext
await site.drive.put(path, buf)
return { ok: true, path, keyHex: site.keyHex || (site.drive.key && site.drive.key.toString('hex')) }
}
/**
* Publish a site (start swarming so peers can access it)
*/
async publishSite (siteId) {
const site = this.sites.get(siteId)
if (!site) throw new Error('Site not found: ' + siteId)
this.swarm.join(site.drive.discoveryKey, { server: true, client: false })
// Do NOT await swarm.flush() — it can block >10s on DHT propagation
// and stalls other RPC calls that need the swarm. Flush in background.
this.swarm.flush().catch(() => {})
site.published = true
return {
siteId,
keyHex: site.keyHex,
url: `hyper://${site.keyHex}`
}
}
/**
* Unpublish (stop swarming)
*/
async unpublishSite (siteId) {
const site = this.sites.get(siteId)
if (!site) throw new Error('Site not found: ' + siteId)
try { await this.swarm.leave(site.drive.discoveryKey) } catch {}
site.published = false
return { siteId }
}
/**
* Delete a site entirely
*/
async deleteSite (siteId) {
const site = this.sites.get(siteId)
if (!site) return false
// Leave the swarm topic first so we stop announcing the drive.
// Do NOT call drive.close() — closing a Hyperdrive whose core lives
// in the shared Corestore has been observed to cascade into
// "Corestore is closed" errors on subsequent swarm connections,
// crashing the whole Bare process. The store closes only on app
// shutdown.
if (site.published) {
try { await this.swarm.leave(site.drive.discoveryKey) } catch {}
}
this.sites.delete(siteId)
return true
}
/**
* List all user sites
*/
listSites () {
const result = []
for (const [siteId, site] of this.sites) {
result.push({
siteId,
keyHex: site.keyHex,
name: site.name,
published: site.published,
createdAt: site.createdAt,
url: `hyper://${site.keyHex}`
})
}
return result
}
/**
* Publish raw HTML/CSS/JS files directly (for AI-generated or custom code)
*/
async publishRawHtml (siteId, html, css) {
const site = this.sites.get(siteId)
if (!site) throw new Error('Site not found: ' + siteId)
await site.drive.put('/index.html', Buffer.from(html))
if (css) await site.drive.put('/style.css', Buffer.from(css))
return { siteId }
}
/**
* Write multiple raw files to a site (for full custom uploads)
*/
async writeRawFiles (siteId, files) {
const site = this.sites.get(siteId)
if (!site) throw new Error('Site not found: ' + siteId)
// Validate files array
if (!Array.isArray(files)) {
throw new Error('Files must be an array')
}
const MAX_FILES = 100
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
if (files.length > MAX_FILES) {
throw new Error(`Too many files. Maximum is ${MAX_FILES}`)
}
for (const file of files) {
// Validate file path
if (!this._validateFilePath(file.path)) {
throw new Error(`Invalid file path: ${file.path}`)
}
// Validate content
if (typeof file.content !== 'string') {
throw new Error(`Invalid content for ${file.path}`)
}
if (file.content.length > MAX_FILE_SIZE) {
throw new Error(`File too large: ${file.path}`)
}
await site.drive.put(file.path, Buffer.from(file.content))
}
return { siteId, filesWritten: files.length }
}
/**
* Build a site from template + user content blocks
*/
async buildFromBlocks (siteId, blocks, theme) {
const site = this.sites.get(siteId)
if (!site) throw new Error('Site not found: ' + siteId)
const html = this._renderBlocks(blocks, site.name, theme)
const css = this._renderThemeCss(theme)
await site.drive.put('/index.html', Buffer.from(html))
await site.drive.put('/style.css', Buffer.from(css))
// Persist the source blocks so the editor can reload them. Hidden
// from the rendered site but inside the drive itself — travels with
// the site key.
await site.drive.put('/.blocks.json', Buffer.from(JSON.stringify({ blocks, theme: theme || null, name: site.name })))
return { siteId }
}
async getSiteBlocks (siteId) {
const site = this.sites.get(siteId)
if (!site) throw new Error('Site not found: ' + siteId)
try {
const buf = await site.drive.get('/.blocks.json')
if (!buf) return { blocks: [], theme: null }
const parsed = JSON.parse(buf.toString())
return { blocks: parsed.blocks || [], theme: parsed.theme || null }
} catch {
return { blocks: [], theme: null }
}
}
_renderBlocks (blocks, siteName, theme) {
const bodyHtml = blocks.map(block => {
switch (block.type) {
case 'heading':
return `<h${block.level || 1}>${this._escapeHtml(block.text)}</h${block.level || 1}>`
case 'text':
return `<p>${this._escapeHtml(block.text)}</p>`
case 'image':
return `<img src="${this._escapeHtml(block.src)}" alt="${this._escapeHtml(block.alt || '')}">`
case 'link':
return `<a href="${this._escapeHtml(block.href)}">${this._escapeHtml(block.text || block.href)}</a>`
case 'divider':
return '<hr>'
case 'code':
return `<pre><code>${this._escapeHtml(block.text)}</code></pre>`
case 'html':
// Raw HTML/CSS/JS written directly into the page (no escaping).
// Scoped to this user's own site — site author controls the content.
return block.text || ''
case 'quote':
return `<blockquote>${this._escapeHtml(block.text)}</blockquote>`
case 'list':
return `<ul>${(block.items || []).map(i => `<li>${this._escapeHtml(i)}</li>`).join('\n')}</ul>`
default:
return ''
}
}).join('\n')
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${this._escapeHtml(siteName)}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
${bodyHtml}
</main>
</body>
</html>`
}
_renderThemeCss (theme = {}) {
const primary = theme.primaryColor || '#ff9500'
const bg = theme.backgroundColor || '#0a0a0a'
const text = theme.textColor || '#e0e0e0'
const font = theme.fontFamily || '-apple-system, sans-serif'
return `
:root {
--primary: ${primary};
--bg: ${bg};
--text: ${text};
--font: ${font};
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.6; }
main { max-width: 680px; margin: 0 auto; padding: 40px 20px; }
h1, h2, h3 { color: var(--primary); margin-bottom: 16px; }
p { margin-bottom: 16px; }
a { color: var(--primary); }
img { max-width: 100%; border-radius: 8px; margin: 16px 0; }
hr { border: none; border-top: 1px solid #333; margin: 32px 0; }
pre { background: #1a1a1a; padding: 16px; border-radius: 8px; overflow-x: auto; margin: 16px 0; }
code { font-family: monospace; font-size: 14px; }
blockquote { border-left: 3px solid var(--primary); padding-left: 16px; color: #888; margin: 16px 0; }
`
}
_defaultHtml (name) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${this._escapeHtml(name || 'My Site')}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<h1>${this._escapeHtml(name || 'My Site')}</h1>
<p>Welcome to my P2P website, served over Hyperdrive.</p>
<p>Edit this page in PearBrowser's Site Builder.</p>
</main>
</body>
</html>`
}
_defaultCss () {
return this._renderThemeCss()
}
_escapeHtml (str) {
// SECURITY: Comprehensive HTML escaping to prevent XSS
if (typeof str !== 'string') return ''
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/`/g, '`')
.replace(/\\/g, '\')
}
// Validate site name to prevent XSS via malicious names
_validateSiteName (name) {
if (typeof name !== 'string') return 'My Site'
// Limit length and remove dangerous characters
const sanitized = name.slice(0, 100).replace(/[<>\"'`]/g, '')
return sanitized || 'My Site'
}
// Validate file paths to prevent path traversal
_validateFilePath (path) {
if (typeof path !== 'string') return false
// Reject paths with directory traversal attempts
if (path.includes('..') || path.includes('\x00')) return false
// Must start with /
if (!path.startsWith('/')) return false
// Limit path length
if (path.length > 1024) return false
// Only allow safe characters
if (!/^[\w\-/.]+$/.test(path)) return false
return true
}
export () {
const out = {}
for (const [siteId, site] of this.sites) {
out[siteId] = {
keyHex: site.keyHex,
name: site.name,
published: site.published,
createdAt: site.createdAt
}
}
return out
}
/**
* Import previously persisted sites and reopen their Hyperdrives.
*/
async import (data) {
if (!data || typeof data !== 'object') return
for (const [siteId, info] of Object.entries(data)) {
try {
const keyHex = typeof info?.keyHex === 'string' ? info.keyHex : ''
if (!/^[0-9a-f]{64}$/i.test(keyHex)) continue
const drive = new Hyperdrive(this.store, Buffer.from(keyHex, 'hex'))
await drive.ready()
const published = !!info.published
if (published) {
this.swarm.join(drive.discoveryKey, { server: true, client: false })
}
this.sites.set(siteId, {
drive,
keyHex,
name: this._validateSiteName(info.name || 'My Site'),
published,
createdAt: typeof info.createdAt === 'number' ? info.createdAt : Date.now()
})
} catch (err) {
console.error('Failed to restore site:', siteId, err.message)
}
}
}
async close () {
for (const [, site] of this.sites) {
if (site.published) {
try { await this.swarm.leave(site.drive.discoveryKey) } catch {}
}
try { await site.drive.close() } catch {}
}
this.sites.clear()
}
}
module.exports = { SiteManager }