Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at main 6.3 kB view raw
1import { Elysia } from 'elysia' 2import type { Context } from 'elysia' 3import { cors } from '@elysiajs/cors' 4import { staticPlugin } from '@elysiajs/static' 5 6import type { Config } from './lib/types' 7import { BASE_HOST } from '@wisp/constants' 8import { 9 createClientMetadata, 10 getOAuthClient, 11 getCurrentKeys, 12 cleanupExpiredSessions, 13 rotateKeysIfNeeded 14} from './lib/oauth-client' 15import { getCookieSecret } from './lib/db' 16import { authRoutes } from './routes/auth' 17import { wispRoutes } from './routes/wisp' 18import { domainRoutes } from './routes/domain' 19import { userRoutes } from './routes/user' 20import { siteRoutes } from './routes/site' 21import { csrfProtection } from './lib/csrf' 22import { DNSVerificationWorker } from './lib/dns-verification-worker' 23import { createLogger, logCollector, initializeGrafanaExporters } from '@wisp/observability' 24import { observabilityMiddleware } from '@wisp/observability/middleware/elysia' 25import { promptAdminSetup } from './lib/admin-auth' 26import { adminRoutes } from './routes/admin' 27 28// Initialize Grafana exporters if configured 29initializeGrafanaExporters({ 30 serviceName: 'main-app', 31 serviceVersion: '1.0.50' 32}) 33 34const logger = createLogger('main-app') 35 36const config: Config = { 37 domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'], 38 clientName: Bun.env.CLIENT_NAME ?? 'PDS-View' 39} 40 41// Initialize admin setup (prompt if no admin exists) 42await promptAdminSetup() 43 44// Get or generate cookie signing secret 45const cookieSecret = await getCookieSecret() 46 47const client = await getOAuthClient(config) 48 49// Periodic maintenance: cleanup expired sessions and rotate keys 50// Run every hour 51const runMaintenance = async () => { 52 console.log('[Maintenance] Running periodic maintenance...') 53 await cleanupExpiredSessions() 54 await rotateKeysIfNeeded() 55} 56 57// Run maintenance on startup 58runMaintenance() 59 60// Schedule maintenance to run every hour 61setInterval(runMaintenance, 60 * 60 * 1000) 62 63// Start DNS verification worker (runs every 10 minutes) 64const dnsVerifier = new DNSVerificationWorker( 65 10 * 60 * 1000, // 10 minutes 66 (msg, data) => { 67 logCollector.info(`[DNS Verifier] ${msg}`, 'main-app', data ? { data } : undefined) 68 } 69) 70 71dnsVerifier.start() 72logger.info('DNS Verifier Started - checking custom domains every 10 minutes') 73 74export const app = new Elysia({ 75 serve: { 76 maxRequestBodySize: 1024 * 1024 * 128 * 3, 77 development: Bun.env.NODE_ENV !== 'production' ? true : false, 78 id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 79 }, 80 cookie: { 81 secrets: cookieSecret, 82 sign: ['did'] 83 } 84 }) 85 // Observability middleware 86 .onBeforeHandle(observabilityMiddleware('main-app').beforeHandle) 87 .onAfterHandle((ctx: Context) => { 88 observabilityMiddleware('main-app').afterHandle(ctx) 89 // Security headers middleware 90 const { set } = ctx 91 // Prevent clickjacking attacks 92 set.headers['X-Frame-Options'] = 'DENY' 93 // Prevent MIME type sniffing 94 set.headers['X-Content-Type-Options'] = 'nosniff' 95 // Strict Transport Security (HSTS) - enforce HTTPS 96 set.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' 97 // Referrer policy - limit referrer information 98 set.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' 99 // Content Security Policy 100 set.headers['Content-Security-Policy'] = 101 "default-src 'self'; " + 102 "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + 103 "style-src 'self' 'unsafe-inline'; " + 104 "img-src 'self' data: https:; " + 105 "font-src 'self' data:; " + 106 "connect-src 'self' https:; " + 107 "frame-ancestors 'none'; " + 108 "base-uri 'self'; " + 109 "form-action 'self'" 110 // Additional security headers 111 set.headers['X-XSS-Protection'] = '1; mode=block' 112 set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' 113 }) 114 .onError(observabilityMiddleware('main-app').onError) 115 .use(csrfProtection()) 116 .use(authRoutes(client, cookieSecret)) 117 .use(wispRoutes(client, cookieSecret)) 118 .use(domainRoutes(client, cookieSecret)) 119 .use(userRoutes(client, cookieSecret)) 120 .use(siteRoutes(client, cookieSecret)) 121 .use(adminRoutes(cookieSecret)) 122 .use( 123 await staticPlugin({ 124 assets: './apps/main-app/public', 125 prefix: '/', 126 }) 127 ) 128 .get('/client-metadata.json', () => { 129 return createClientMetadata(config) 130 }) 131 .get('/jwks.json', async ({ set }) => { 132 // Prevent caching to ensure clients always get fresh keys after rotation 133 set.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' 134 set.headers['Pragma'] = 'no-cache' 135 set.headers['Expires'] = '0' 136 137 const keys = await getCurrentKeys() 138 if (!keys.length) return { keys: [] } 139 140 return { 141 keys: keys.map((k) => { 142 const jwk = k.publicJwk ?? k 143 const { ...pub } = jwk 144 return pub 145 }) 146 } 147 }) 148 .get('/api/health', () => { 149 const dnsVerifierHealth = dnsVerifier.getHealth() 150 return { 151 status: 'ok', 152 timestamp: new Date().toISOString(), 153 dnsVerifier: dnsVerifierHealth 154 } 155 }) 156 .get('/api/screenshots', async () => { 157 const fs = await import('fs/promises') 158 159 try { 160 const screenshotsDir = './apps/main-app/public/screenshots' 161 const files = await fs.readdir(screenshotsDir) 162 const screenshots = files.filter(file => file.endsWith('.png')) 163 return { screenshots } 164 } catch (error) { 165 return { screenshots: [] } 166 } 167 }) 168 .get('/api/admin/test', () => { 169 return { message: 'Admin routes test works!' } 170 }) 171 .post('/api/admin/verify-dns', async () => { 172 try { 173 await dnsVerifier.trigger() 174 return { 175 success: true, 176 message: 'DNS verification triggered' 177 } 178 } catch (error) { 179 return { 180 success: false, 181 error: error instanceof Error ? error.message : String(error) 182 } 183 } 184 }) 185 .get('/.well-known/atproto-did', ({ set }) => { 186 // Return plain text DID for AT Protocol domain verification 187 set.headers['Content-Type'] = 'text/plain' 188 return 'did:plc:7puq73yz2hkvbcpdhnsze2qw' 189 }) 190 .use(cors({ 191 origin: config.domain, 192 credentials: true, 193 methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'], 194 allowedHeaders: ['Content-Type', 'Authorization', 'Origin', 'X-Forwarded-Host'], 195 exposeHeaders: ['Content-Type'], 196 maxAge: 86400 // 24 hours 197 })) 198 .listen(8000) 199 200console.log( 201 `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` 202)