| … | … | @@ -271,40 +271,6 @@ class HypernoteServer { |
| 271 | 271 | }) |
| 272 | 272 | }) |
| 273 | 273 | |
| 274 | // Diagnostic: dump identity chain for a pubkey (localhost only) | |
| 275 | this.router.get('/api/debug/identity-chain', async (req, res, server) => { | |
| 276 | const ip = req.socket?.remoteAddress || '' | |
| 277 | if (!ip.includes('127.0.0.1') && !ip.includes('::1') && !ip.includes('::ffff:127.0.0.1')) { | |
| 278 | json(res, { error: 'localhost only' }, 403) | |
| 279 | return | |
| 280 | } | |
| 281 | const pubkey = req.url.includes('pubkey=') ? decodeURIComponent(req.url.split('pubkey=')[1].split('&')[0]) : null | |
| 282 | const result = { pubkey } | |
| 283 | if (pubkey) { | |
| 284 | const pe = await server.authManager.identityRegistry.db.get(`pubkey:${pubkey}`) | |
| 285 | result.pubkeyEntry = pe ? pe.value : null | |
| 286 | const ie = await server.authManager.identityRegistry.db.get(`identity:${pubkey}`) | |
| 287 | result.identityEntry = ie ? ie.value : null | |
| 288 | if (ie && ie.value && ie.value.userId) { | |
| 289 | const ue = await server.authManager.identityRegistry.db.get(`user:${ie.value.userId}`) | |
| 290 | result.userEntry = ue ? ue.value : null | |
| 291 | } | |
| 292 | if (server.authManager.sharedRegistry) { | |
| 293 | try { | |
| 294 | const su = await server.authManager.sharedRegistry.getSharedUserByPublicKey(pubkey) | |
| 295 | result.sharedRegistryUser = su || null | |
| 296 | } catch {} | |
| 297 | } | |
| 298 | } | |
| 299 | const users = [] | |
| 300 | for await (const entry of server.authManager.identityRegistry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) { | |
| 301 | const u = entry.value | |
| 302 | users.push({ userId: u.userId, username: u.username, admin: u.globalPermissions?.includes('admin'), identities: u.identities, mergedInto: u.mergedInto }) | |
| 303 | } | |
| 304 | result.allUsers = users | |
| 305 | json(res, result) | |
| 306 | }) | |
| 307 | ||
| 308 | 274 | // Phase 2: Notebook + Note + Search routes |
| 309 | 275 | registerNotebookRoutes(this.router, this) |
| 310 | 276 | |
| … | … | @@ -666,10 +632,22 @@ class HypernoteServer { |
| 666 | 632 | async start() { |
| 667 | 633 | await this.ready() |
| 668 | 634 | |
| 635 | // Build CORS origin whitelist from server config | |
| 636 | const allowedOrigins = new Set([ | |
| 637 | `http://localhost:${this.port}`, | |
| 638 | `http://127.0.0.1:${this.port}`, | |
| 639 | `http://[::1]:${this.port}` | |
| 640 | ]) | |
| 641 | // In network mode, also allow LAN hostname | |
| 642 | if (this.host === '0.0.0.0') { | |
| 643 | const lanAddr = HypernoteServer._getLanAddress() | |
| 644 | if (lanAddr) allowedOrigins.add(`http://${lanAddr}:${this.port}`) | |
| 645 | } | |
| 646 | ||
| 669 | 647 | const server = http.createServer(async (req, res) => { |
| 670 | // CORS — allow hyper:// and other origins to call the API | |
| 648 | // CORS — whitelist-based origin checking | |
| 671 | 649 | const origin = req.headers.origin || '' |
| 672 | if (origin) { | |
| 650 | if (origin && (allowedOrigins.has(origin) || origin.startsWith('hyper://'))) { | |
| 673 | 651 | res.setHeader('Access-Control-Allow-Origin', origin) |
| 674 | 652 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') |
| 675 | 653 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Wirecore-PublicKey, X-Wirecore-Signature, X-Wirecore-Challenge') |
| … | … | @@ -689,11 +667,12 @@ class HypernoteServer { |
| 689 | 667 | notFound(res, 'Route not found') |
| 690 | 668 | } |
| 691 | 669 | } catch (err) { |
| 692 | console.error('[Hypernote] Request error:', err) | |
| 693 | 670 | if (!res.writableEnded) { |
| 694 | res.statusCode = 500 | |
| 671 | const status = err.statusCode || 500 | |
| 672 | if (status >= 500) console.error('[Hypernote] Request error:', err.message) | |
| 673 | res.statusCode = status | |
| 695 | 674 | res.setHeader('Content-Type', 'application/json') |
| 696 | res.end(JSON.stringify({ error: 'Internal server error' })) | |
| 675 | res.end(JSON.stringify({ error: status >= 500 ? 'Internal server error' : err.message })) | |
| 697 | 676 | } |
| 698 | 677 | } |
| 699 | 678 | }) |
| … | … | @@ -104,7 +104,6 @@ export class AuthManager { |
| 104 | 104 | |
| 105 | 105 | // For other methods, look up User via Identity |
| 106 | 106 | let user = await this.identityRegistry.getUserByIdentity(authResult.identityId) |
| 107 | console.log(`[Auth] Provider ${authResult.method}: identityId=${authResult.identityId}, user=${user ? user.userId : 'null'}, sharedRegistry=${!!this.sharedRegistry}`) | |
| 108 | 107 | |
| 109 | 108 | if (!user && authResult.method === 'web-identity') { |
| 110 | 109 | // Web identity was auto-registered by provider, retry lookup |
| … | … | @@ -13,7 +13,7 @@ import b4a from 'b4a' |
| 13 | 13 | */ |
| 14 | 14 | export class ChallengeManager { |
| 15 | 15 | constructor(options = {}) { |
| 16 | this.ttl = options.ttl || 5 * 60 * 1000 // 5 minutes default | |
| 16 | this.ttl = options.ttl || 60 * 1000 // 60 seconds default (short to limit replay window) | |
| 17 | 17 | |
| 18 | 18 | // Persistent storage (optional) |
| 19 | 19 | this.db = options.db || null |
| … | … | @@ -22,10 +22,30 @@ export class ChallengeManager { |
| 22 | 22 | this.cache = new Map() |
| 23 | 23 | this.cacheTimeout = options.cacheTimeout || 30 * 1000 // 30 seconds |
| 24 | 24 | |
| 25 | // Per-challenge locks to prevent TOCTOU race conditions | |
| 26 | this._locks = new Map() | |
| 27 | ||
| 25 | 28 | // Clean up expired challenges every minute |
| 26 | 29 | this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 1000) |
| 27 | 30 | } |
| 28 | 31 | |
| 32 | /** | |
| 33 | * Acquire a per-challenge lock to prevent concurrent consume/verify. | |
| 34 | * Returns a release function. | |
| 35 | */ | |
| 36 | async _acquireLock (challengeId) { | |
| 37 | while (this._locks.has(challengeId)) { | |
| 38 | await this._locks.get(challengeId) | |
| 39 | } | |
| 40 | let release | |
| 41 | const promise = new Promise(resolve => { release = resolve }) | |
| 42 | this._locks.set(challengeId, promise) | |
| 43 | return () => { | |
| 44 | this._locks.delete(challengeId) | |
| 45 | release() | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 29 | 49 | /** |
| 30 | 50 | * Generate new challenge |
| 31 | 51 | * @param {Object} options - { context } for logging |
| … | … | @@ -110,31 +130,33 @@ export class ChallengeManager { |
| 110 | 130 | * @returns {Promise<boolean>} True if consumed successfully |
| 111 | 131 | */ |
| 112 | 132 | async consume(challengeId) { |
| 113 | // Atomically check and mark as used to prevent race conditions | |
| 114 | let active = this.cache.get(challengeId) | |
| 115 | if (active && !active.used && Date.now() <= active.expiresAt) { | |
| 116 | active.used = true | |
| 117 | this.cache.set(challengeId, active) | |
| 118 | } else { | |
| 119 | // Fall back to storage lookup | |
| 120 | active = await this.loadChallenge(challengeId) | |
| 121 | if (!active || active.used || Date.now() > active.expiresAt) return false | |
| 122 | active.used = true | |
| 123 | active.cachedAt = Date.now() | |
| 124 | this.cache.set(challengeId, active) | |
| 125 | } | |
| 133 | const release = await this._acquireLock(challengeId) | |
| 134 | try { | |
| 135 | let active = this.cache.get(challengeId) | |
| 136 | if (active && !active.used && Date.now() <= active.expiresAt) { | |
| 137 | active.used = true | |
| 138 | this.cache.set(challengeId, active) | |
| 139 | } else { | |
| 140 | active = await this.loadChallenge(challengeId) | |
| 141 | if (!active || active.used || Date.now() > active.expiresAt) return false | |
| 142 | active.used = true | |
| 143 | active.cachedAt = Date.now() | |
| 144 | this.cache.set(challengeId, active) | |
| 145 | } | |
| 126 | 146 | |
| 127 | // Update in storage | |
| 128 | if (this.db) { | |
| 129 | try { | |
| 130 | const { cachedAt, ...data } = active | |
| 131 | await this.db.put(`challenge:${challengeId}`, data) | |
| 132 | } catch (err) { | |
| 133 | console.warn('[Challenge] Failed to mark challenge as used:', err.message) | |
| 147 | if (this.db) { | |
| 148 | try { | |
| 149 | const { cachedAt, ...data } = active | |
| 150 | await this.db.put(`challenge:${challengeId}`, data) | |
| 151 | } catch (err) { | |
| 152 | console.warn('[Challenge] Failed to mark challenge as used:', err.message) | |
| 153 | } | |
| 134 | 154 | } |
| 135 | } | |
| 136 | 155 | |
| 137 | return true | |
| 156 | return true | |
| 157 | } finally { | |
| 158 | release() | |
| 159 | } | |
| 138 | 160 | } |
| 139 | 161 | |
| 140 | 162 | /** |
| … | … | @@ -144,59 +166,48 @@ export class ChallengeManager { |
| 144 | 166 | * @returns {Promise<boolean>} True if valid and not expired |
| 145 | 167 | */ |
| 146 | 168 | async verify(challengeId, challenge) { |
| 147 | const now = Date.now() | |
| 169 | const release = await this._acquireLock(challengeId) | |
| 170 | try { | |
| 171 | const now = Date.now() | |
| 148 | 172 | |
| 149 | // Try cache first | |
| 150 | let record = this.cache.get(challengeId) | |
| 173 | let record = this.cache.get(challengeId) | |
| 151 | 174 | |
| 152 | // Load from storage if not in cache or cache expired | |
| 153 | if (!record || (record.cachedAt && now - record.cachedAt > this.cacheTimeout)) { | |
| 154 | record = await this.loadChallenge(challengeId) | |
| 155 | if (record) { | |
| 156 | record.cachedAt = now | |
| 157 | this.cache.set(challengeId, record) | |
| 175 | if (!record || (record.cachedAt && now - record.cachedAt > this.cacheTimeout)) { | |
| 176 | record = await this.loadChallenge(challengeId) | |
| 177 | if (record) { | |
| 178 | record.cachedAt = now | |
| 179 | this.cache.set(challengeId, record) | |
| 180 | } | |
| 158 | 181 | } |
| 159 | } | |
| 160 | 182 | |
| 161 | if (!record) { | |
| 162 | console.warn('[Challenge] Unknown challenge:', challengeId) | |
| 163 | return false | |
| 164 | } | |
| 183 | if (!record) return false | |
| 165 | 184 | |
| 166 | // Check expiry | |
| 167 | if (now > record.expiresAt) { | |
| 168 | console.warn('[Challenge] Expired challenge:', challengeId) | |
| 169 | await this.deleteChallenge(challengeId) | |
| 170 | return false | |
| 171 | } | |
| 185 | if (now > record.expiresAt) { | |
| 186 | await this.deleteChallenge(challengeId) | |
| 187 | return false | |
| 188 | } | |
| 172 | 189 | |
| 173 | // Check if already used (prevent replay) | |
| 174 | if (record.used) { | |
| 175 | console.warn('[Challenge] Challenge already used:', challengeId) | |
| 176 | return false | |
| 177 | } | |
| 190 | if (record.used) return false | |
| 178 | 191 | |
| 179 | // Verify challenge matches | |
| 180 | if (record.challenge !== challenge) { | |
| 181 | console.warn('[Challenge] Challenge mismatch:', challengeId) | |
| 182 | return false | |
| 183 | } | |
| 192 | if (record.challenge !== challenge) return false | |
| 184 | 193 | |
| 185 | // Mark as used | |
| 186 | record.used = true | |
| 187 | this.cache.set(challengeId, record) | |
| 194 | // Mark as used atomically (under lock) | |
| 195 | record.used = true | |
| 196 | this.cache.set(challengeId, record) | |
| 188 | 197 | |
| 189 | // Update in storage | |
| 190 | if (this.db) { | |
| 191 | try { | |
| 192 | const { cachedAt, ...data } = record | |
| 193 | await this.db.put(`challenge:${challengeId}`, data) | |
| 194 | } catch (err) { | |
| 195 | console.warn('[Challenge] Failed to mark challenge as used:', err.message) | |
| 198 | if (this.db) { | |
| 199 | try { | |
| 200 | const { cachedAt, ...data } = record | |
| 201 | await this.db.put(`challenge:${challengeId}`, data) | |
| 202 | } catch (err) { | |
| 203 | console.warn('[Challenge] Failed to mark challenge as used:', err.message) | |
| 204 | } | |
| 196 | 205 | } |
| 197 | } | |
| 198 | 206 | |
| 199 | return true | |
| 207 | return true | |
| 208 | } finally { | |
| 209 | release() | |
| 210 | } | |
| 200 | 211 | } |
| 201 | 212 | |
| 202 | 213 | /** |
| … | … | @@ -274,9 +285,7 @@ export class ChallengeManager { |
| 274 | 285 | } |
| 275 | 286 | } |
| 276 | 287 | |
| 277 | if (cleaned > 0) { | |
| 278 | console.log(`[Challenge] Cleaned up ${cleaned} expired challenges`) | |
| 279 | } | |
| 288 | // Cleanup runs silently | |
| 280 | 289 | } |
| 281 | 290 | |
| 282 | 291 | /** |
| … | … | @@ -24,7 +24,6 @@ export class BearerTokenProvider extends AuthProvider { |
| 24 | 24 | * - Authorization: Bearer <token> |
| 25 | 25 | * - Authorization: Token <token> |
| 26 | 26 | * - X-Auth-Token header |
| 27 | * - ?token=<token> query parameter | |
| 28 | 27 | */ |
| 29 | 28 | async extractCredentials(req) { |
| 30 | 29 | // Check Authorization header |
| … | … | @@ -38,17 +37,6 @@ export class BearerTokenProvider extends AuthProvider { |
| 38 | 37 | const tokenHeader = req.headers['x-auth-token'] || req.headers['X-Auth-Token'] |
| 39 | 38 | if (tokenHeader) return tokenHeader |
| 40 | 39 | |
| 41 | // Check query parameter | |
| 42 | if (req.url) { | |
| 43 | try { | |
| 44 | const url = new URL(req.url, `http://${req.headers.host}`) | |
| 45 | const tokenParam = url.searchParams.get('token') | |
| 46 | if (tokenParam) return tokenParam | |
| 47 | } catch { | |
| 48 | // Ignore URL parsing errors | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | 40 | return null |
| 53 | 41 | } |
| 54 | 42 |
| … | … | @@ -65,7 +65,7 @@ export class KeetIdentityProvider extends AuthProvider { |
| 65 | 65 | credentials: { publicKey } |
| 66 | 66 | }) |
| 67 | 67 | |
| 68 | console.log(`[KeetIdentity] Auto-registered: ${publicKey.substring(0, 8)}... → user ${user.userId}`) | |
| 68 | console.log('[KeetIdentity] Auto-registered new identity') | |
| 69 | 69 | |
| 70 | 70 | return { |
| 71 | 71 | identityId: newIdentity.identityId, |
| … | … | @@ -9,8 +9,11 @@ |
| 9 | 9 | import { SharedRegistry } from './shared-registry.js' |
| 10 | 10 | import { SharedNotebook } from './shared-notebook.js' |
| 11 | 11 | import { InstanceLinker } from './instance-linker.js' |
| 12 | import mutexify from 'mutexify/promise.js' | |
| 12 | 13 | import crypto from 'hypercore-crypto' |
| 13 | 14 | |
| 15 | const CALLBACK_TOKEN_TTL = 30 * 60 * 1000 // 30 minutes | |
| 16 | ||
| 14 | 17 | export class MultiwriterManager { |
| 15 | 18 | constructor (corestore, opts = {}) { |
| 16 | 19 | this._store = corestore |
| … | … | @@ -23,10 +26,13 @@ export class MultiwriterManager { |
| 23 | 26 | this._sharedNotebooks = new Map() // slug -> SharedNotebook |
| 24 | 27 | this._ready = false |
| 25 | 28 | this._syncInterval = null |
| 29 | this._initialSyncTimeout = null | |
| 26 | 30 | this._driveManager = null |
| 27 | 31 | this._replicationManager = null |
| 28 | 32 | this._identityRegistry = null |
| 29 | this._pendingCallbackTokens = new Set() // one-time tokens for add-writer callback | |
| 33 | this._pendingCallbackTokens = new Map() // token -> { createdAt } | |
| 34 | this._lock = mutexify() // serialize state transitions | |
| 35 | this._addedNbWriters = new Set() | |
| 30 | 36 | } |
| 31 | 37 | |
| 32 | 38 | get registry () { |
| … | … | @@ -113,7 +119,7 @@ export class MultiwriterManager { |
| 113 | 119 | async createLinkInvite () { |
| 114 | 120 | // Generate a one-time callback token for the add-writer HTTP callback |
| 115 | 121 | const callbackToken = crypto.randomBytes(32).toString('hex') |
| 116 | this._pendingCallbackTokens.add(callbackToken) | |
| 122 | this._pendingCallbackTokens.set(callbackToken, { createdAt: Date.now() }) | |
| 117 | 123 | |
| 118 | 124 | // Ensure linker has current instance info + callback token |
| 119 | 125 | this._linker._instanceKey = this._instanceKey |
| … | … | @@ -123,10 +129,17 @@ export class MultiwriterManager { |
| 123 | 129 | } |
| 124 | 130 | |
| 125 | 131 | async joinWithInvite (inviteHex) { |
| 126 | console.log('[Multiwriter] joinWithInvite: calling linker...') | |
| 132 | const release = await this._lock() | |
| 133 | try { | |
| 134 | return await this._joinWithInviteUnsafe(inviteHex) | |
| 135 | } finally { | |
| 136 | release() | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | async _joinWithInviteUnsafe (inviteHex) { | |
| 127 | 141 | const result = await this._linker.joinWithInvite(inviteHex) |
| 128 | 142 | const { bootstrapKey, writerKey, instanceUrl, callbackToken } = result |
| 129 | console.log('[Multiwriter] joinWithInvite: linker returned, bootstrapKey:', bootstrapKey ? bootstrapKey.substring(0, 16) + '...' : 'NONE') | |
| 130 | 143 | |
| 131 | 144 | if (!bootstrapKey) throw new Error('No bootstrapKey received from source') |
| 132 | 145 | |
| … | … | @@ -134,18 +147,15 @@ export class MultiwriterManager { |
| 134 | 147 | const oldRegistry = this._registry |
| 135 | 148 | if (this._linker) await this._linker.destroy() |
| 136 | 149 | |
| 137 | // 2. Create NEW SharedRegistry with source's bootstrapKey (unique namespace via Step 2) | |
| 150 | // 2. Create NEW SharedRegistry with source's bootstrapKey | |
| 138 | 151 | const bootstrapBuf = Buffer.isBuffer(bootstrapKey) |
| 139 | 152 | ? bootstrapKey : Buffer.from(bootstrapKey, 'hex') |
| 140 | console.log('[Multiwriter] Creating new SharedRegistry with bootstrap key...') | |
| 141 | 153 | this._registry = new SharedRegistry(this._store, { bootstrapKey: bootstrapBuf }) |
| 142 | 154 | await this._registry.ready() |
| 143 | console.log('[Multiwriter] New registry ready') | |
| 144 | 155 | |
| 145 | 156 | // 3. Get our new localWriterKey from the SHARED registry |
| 146 | 157 | const localWriterKey = this._registry.localWriterKey.toString('hex') |
| 147 | console.log(`[Multiwriter] Joined shared registry: ${bootstrapKey.substring(0, 16)}...`) | |
| 148 | console.log(`[Multiwriter] New local writer key: ${localWriterKey.substring(0, 16)}...`) | |
| 158 | console.log('[Multiwriter] Joined shared registry') | |
| 149 | 159 | |
| 150 | 160 | // 4. Re-wire linker with new registry |
| 151 | 161 | this._linker = new InstanceLinker(this._registry, { |
| … | … | @@ -169,7 +179,7 @@ export class MultiwriterManager { |
| 169 | 179 | if (result.instanceKey) { |
| 170 | 180 | await this._registry.linkInstance(result.instanceKey) |
| 171 | 181 | } |
| 172 | console.log(`[Multiwriter] Added source writer: ${writerKey.substring(0, 16)}...`) | |
| 182 | console.log('[Multiwriter] Added source writer') | |
| 173 | 183 | } catch (err) { |
| 174 | 184 | console.error('[Multiwriter] Failed to add source writer:', err.message) |
| 175 | 185 | } |
| … | … | @@ -178,7 +188,7 @@ export class MultiwriterManager { |
| 178 | 188 | // 8. HTTP callback — tell source our new writer key |
| 179 | 189 | if (instanceUrl) { |
| 180 | 190 | try { |
| 181 | console.log(`[Multiwriter] Calling back source at ${instanceUrl}/api/multiwriter/add-writer`) | |
| 191 | console.log('[Multiwriter] Calling back source') | |
| 182 | 192 | const resp = await fetch(`${instanceUrl}/api/multiwriter/add-writer`, { |
| 183 | 193 | method: 'POST', |
| 184 | 194 | headers: { 'Content-Type': 'application/json' }, |
| … | … | @@ -187,7 +197,7 @@ export class MultiwriterManager { |
| 187 | 197 | if (!resp.ok) { |
| 188 | 198 | console.error('[Multiwriter] Callback failed:', resp.status) |
| 189 | 199 | } else { |
| 190 | console.log('[Multiwriter] Callback succeeded — source has our writer key') | |
| 200 | console.log('[Multiwriter] Callback succeeded') | |
| 191 | 201 | } |
| 192 | 202 | } catch (err) { |
| 193 | 203 | console.error('[Multiwriter] Callback to source failed:', err.message) |
| … | … | @@ -216,7 +226,9 @@ export class MultiwriterManager { |
| 216 | 226 | |
| 217 | 227 | // Verify callback token if provided (HTTP callback path) |
| 218 | 228 | if (opts.callbackToken) { |
| 219 | if (!this._pendingCallbackTokens.has(opts.callbackToken)) { | |
| 229 | const tokenEntry = this._pendingCallbackTokens.get(opts.callbackToken) | |
| 230 | if (!tokenEntry || (Date.now() - tokenEntry.createdAt > CALLBACK_TOKEN_TTL)) { | |
| 231 | this._pendingCallbackTokens.delete(opts.callbackToken) | |
| 220 | 232 | throw new Error('Invalid or expired callback token') |
| 221 | 233 | } |
| 222 | 234 | this._pendingCallbackTokens.delete(opts.callbackToken) // one-time use |
| … | … | @@ -242,6 +254,16 @@ export class MultiwriterManager { |
| 242 | 254 | async syncMirror () { |
| 243 | 255 | if (!this._registry || !this._driveManager) return |
| 244 | 256 | |
| 257 | const release = await this._lock() | |
| 258 | try { | |
| 259 | await this._syncMirrorUnsafe() | |
| 260 | } finally { | |
| 261 | release() | |
| 262 | } | |
| 263 | } | |
| 264 | ||
| 265 | async _syncMirrorUnsafe () { | |
| 266 | ||
| 245 | 267 | // 0. Update Autobase to get latest from peers |
| 246 | 268 | try { |
| 247 | 269 | await this._registry.base.update() |
| … | … | @@ -500,7 +522,7 @@ export class MultiwriterManager { |
| 500 | 522 | for (let i = 0; i < users.length; i++) { |
| 501 | 523 | const user = users[i] |
| 502 | 524 | if (!user || !user.userId) { |
| 503 | console.log(`[Mirror] Skipping user[${i}]: no userId`, JSON.stringify(user).substring(0, 100)) | |
| 525 | console.log(`[Mirror] Skipping user[${i}]: no userId`) | |
| 504 | 526 | continue |
| 505 | 527 | } |
| 506 | 528 | |
| … | … | @@ -532,9 +554,8 @@ export class MultiwriterManager { |
| 532 | 554 | publicKey, |
| 533 | 555 | identityMethod |
| 534 | 556 | }) |
| 535 | console.log(`[Mirror] Synced user: ${user.username || user.userId.substring(0, 16)} (${publicKey ? publicKey.substring(0, 16) + '...' : 'no key'})`) | |
| 536 | 557 | } catch (err) { |
| 537 | console.error(`[Mirror] Failed to sync user ${user.userId.substring(0, 16)}:`, err.message) | |
| 558 | console.error('[Mirror] Failed to sync user:', err.message) | |
| 538 | 559 | } |
| 539 | 560 | } |
| 540 | 561 | } catch (err) { |
| … | … | @@ -560,7 +581,7 @@ export class MultiwriterManager { |
| 560 | 581 | if (su.publicKey) { |
| 561 | 582 | const existingRef = await this._identityRegistry.db.get(`pubkey:${su.publicKey}`) |
| 562 | 583 | if (existingRef && existingRef.value) { |
| 563 | console.log(`[Mirror] Skipping import of ${su.username || su.userId.substring(0, 16)}: publicKey already belongs to local user`) | |
| 584 | console.log('[Mirror] Skipping import: publicKey already belongs to local user') | |
| 564 | 585 | continue |
| 565 | 586 | } |
| 566 | 587 | } |
| … | … | @@ -607,7 +628,6 @@ export class MultiwriterManager { |
| 607 | 628 | } |
| 608 | 629 | |
| 609 | 630 | imported++ |
| 610 | console.log(`[Mirror] Imported shared user: ${su.username || su.userId.substring(0, 16)} (${su.publicKey ? su.publicKey.substring(0, 16) + '...' : 'no key'})`) | |
| 611 | 631 | } |
| 612 | 632 | if (imported > 0) { |
| 613 | 633 | console.log(`[Mirror] Imported ${imported} users from shared registry`) |
| … | … | @@ -624,7 +644,8 @@ export class MultiwriterManager { |
| 624 | 644 | if (this._syncInterval) return |
| 625 | 645 | |
| 626 | 646 | // Initial sync after a short delay (let replication establish) |
| 627 | setTimeout(() => { | |
| 647 | this._initialSyncTimeout = setTimeout(() => { | |
| 648 | this._initialSyncTimeout = null | |
| 628 | 649 | this.syncMirror().catch(err => { |
| 629 | 650 | console.error('[Mirror] Initial sync failed:', err.message) |
| 630 | 651 | }) |
| … | … | @@ -639,6 +660,10 @@ export class MultiwriterManager { |
| 639 | 660 | } |
| 640 | 661 | |
| 641 | 662 | stopMirrorSync () { |
| 663 | if (this._initialSyncTimeout) { | |
| 664 | clearTimeout(this._initialSyncTimeout) | |
| 665 | this._initialSyncTimeout = null | |
| 666 | } | |
| 642 | 667 | if (this._syncInterval) { |
| 643 | 668 | clearInterval(this._syncInterval) |
| 644 | 669 | this._syncInterval = null |
| … | … | @@ -41,6 +41,7 @@ export class SharedNotebook { |
| 41 | 41 | // Local blob store — each instance gets its own writable core for media |
| 42 | 42 | this._blobCore = this._store.get({ name: 'local-blobs' }) |
| 43 | 43 | this._blobs = new Hyperblobs(this._blobCore) |
| 44 | this._remoteBlobsCache = new Map() // hex key -> Hyperblobs | |
| 44 | 45 | } |
| 45 | 46 | |
| 46 | 47 | async ready () { |
| … | … | @@ -251,13 +252,18 @@ export class SharedNotebook { |
| 251 | 252 | } |
| 252 | 253 | |
| 253 | 254 | async getBlob (id) { |
| 254 | // If blob is on a remote core, open a Hyperblobs session for it | |
| 255 | // If blob is on a remote core, reuse cached Hyperblobs session | |
| 255 | 256 | if (id.coreKey) { |
| 257 | const keyHex = typeof id.coreKey === 'string' ? id.coreKey : id.coreKey.toString('hex') | |
| 256 | 258 | const keyBuf = typeof id.coreKey === 'string' ? Buffer.from(id.coreKey, 'hex') : id.coreKey |
| 257 | 259 | if (!this._blobCore.key.equals(keyBuf)) { |
| 258 | const remoteCore = this._store.get(keyBuf) | |
| 259 | await remoteCore.ready() | |
| 260 | const remoteBlobs = new Hyperblobs(remoteCore) | |
| 260 | let remoteBlobs = this._remoteBlobsCache.get(keyHex) | |
| 261 | if (!remoteBlobs) { | |
| 262 | const remoteCore = this._store.get(keyBuf) | |
| 263 | await remoteCore.ready() | |
| 264 | remoteBlobs = new Hyperblobs(remoteCore) | |
| 265 | this._remoteBlobsCache.set(keyHex, remoteBlobs) | |
| 266 | } | |
| 261 | 267 | return remoteBlobs.get(id) |
| 262 | 268 | } |
| 263 | 269 | } |
| … | … | @@ -278,6 +284,7 @@ export class SharedNotebook { |
| 278 | 284 | async close () { |
| 279 | 285 | this._closed = true |
| 280 | 286 | this._docs.clear() |
| 287 | this._remoteBlobsCache.clear() | |
| 281 | 288 | await this._base.close() |
| 282 | 289 | } |
| 283 | 290 | } |
| … | … | @@ -26,15 +26,8 @@ export class ReplicationManager { |
| 26 | 26 | this.swarm.on('connection', (conn, info) => { |
| 27 | 27 | this.store.replicate(conn) |
| 28 | 28 | this._peerCount++ |
| 29 | console.log(`[Replication] Peer connected (${this._peerCount} total), remote: ${info.publicKey.toString('hex').substring(0, 16)}...`) | |
| 30 | conn.on('close', () => { | |
| 31 | this._peerCount-- | |
| 32 | console.log(`[Replication] Peer disconnected (${this._peerCount} total)`) | |
| 33 | }) | |
| 34 | conn.on('error', (err) => { | |
| 35 | this._peerCount-- | |
| 36 | console.log(`[Replication] Peer error: ${err.message} (${this._peerCount} total)`) | |
| 37 | }) | |
| 29 | conn.on('close', () => { this._peerCount-- }) | |
| 30 | conn.on('error', () => { this._peerCount-- }) | |
| 38 | 31 | }) |
| 39 | 32 | } |
| 40 | 33 |
| … | … | @@ -128,12 +128,19 @@ export async function readBody(req) { |
| 128 | 128 | } |
| 129 | 129 | |
| 130 | 130 | /** |
| 131 | * Read and parse request body as JSON | |
| 131 | * Read and parse request body as JSON. | |
| 132 | * Returns empty object on empty body or malformed JSON. | |
| 132 | 133 | */ |
| 133 | 134 | export async function readJson(req) { |
| 134 | 135 | const body = await readBody(req) |
| 135 | 136 | if (!body) return {} |
| 136 | return JSON.parse(body) | |
| 137 | try { | |
| 138 | return JSON.parse(body) | |
| 139 | } catch { | |
| 140 | const err = new Error('Invalid JSON in request body') | |
| 141 | err.statusCode = 400 | |
| 142 | throw err | |
| 143 | } | |
| 137 | 144 | } |
| 138 | 145 | |
| 139 | 146 | /** |
| … | … | @@ -14,14 +14,51 @@ function parseQuery (url) { |
| 14 | 14 | const idx = url.indexOf('?') |
| 15 | 15 | if (idx < 0) return {} |
| 16 | 16 | const qs = url.substring(idx + 1) |
| 17 | if (qs.length > 2048) return {} // Prevent DoS via huge query strings | |
| 17 | 18 | const params = {} |
| 18 | for (const pair of qs.split('&')) { | |
| 19 | const pairs = qs.split('&') | |
| 20 | if (pairs.length > 50) return {} // Prevent excessive parameter count | |
| 21 | for (const pair of pairs) { | |
| 19 | 22 | const [k, v] = pair.split('=') |
| 20 | if (k) params[decodeURIComponent(k)] = v ? decodeURIComponent(v) : '' | |
| 23 | if (k) { | |
| 24 | try { | |
| 25 | params[decodeURIComponent(k)] = v ? decodeURIComponent(v) : '' | |
| 26 | } catch { | |
| 27 | // Skip malformed URI components | |
| 28 | } | |
| 29 | } | |
| 21 | 30 | } |
| 22 | 31 | return params |
| 23 | 32 | } |
| 24 | 33 | |
| 34 | const NOTE_ID_RE = /^[a-f0-9]{16}$/ | |
| 35 | const MEDIA_FILENAME_RE = /^[a-zA-Z0-9_\-][a-zA-Z0-9_\-\.]{0,254}$/ | |
| 36 | const NOTEBOOK_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$/ | |
| 37 | ||
| 38 | function validateNoteId (res, id) { | |
| 39 | if (!NOTE_ID_RE.test(id)) { | |
| 40 | json(res, { error: 'Invalid note ID format' }, 400) | |
| 41 | return false | |
| 42 | } | |
| 43 | return true | |
| 44 | } | |
| 45 | ||
| 46 | function validateNotebookSlug (res, slug) { | |
| 47 | if (!NOTEBOOK_SLUG_RE.test(slug)) { | |
| 48 | json(res, { error: 'Invalid notebook slug format' }, 400) | |
| 49 | return false | |
| 50 | } | |
| 51 | return true | |
| 52 | } | |
| 53 | ||
| 54 | function validateMediaFilename (res, filename) { | |
| 55 | if (!MEDIA_FILENAME_RE.test(filename)) { | |
| 56 | json(res, { error: 'Invalid media filename' }, 400) | |
| 57 | return false | |
| 58 | } | |
| 59 | return true | |
| 60 | } | |
| 61 | ||
| 25 | 62 | /** |
| 26 | 63 | * Auth helper — authenticate + check permission, throw on failure |
| 27 | 64 | */ |
| … | … | @@ -51,13 +88,9 @@ export function registerNotebookRoutes (router, server) { |
| 51 | 88 | if (query.mine === 'true') { |
| 52 | 89 | const auth = await server.authManager.authenticate(req) |
| 53 | 90 | if (auth && auth.userId) { |
| 54 | console.log(`[Notebooks] mine=true filter: userId=${auth.userId.substring(0, 16)}, admin=${auth.permissions?.admin}, total=${notebooks.length}`) | |
| 55 | 91 | notebooks = notebooks.filter(nb => { |
| 56 | const match = nb.ownerId === auth.userId || (!nb.ownerId && auth.permissions && auth.permissions.admin) | |
| 57 | if (!match) console.log(`[Notebooks] Filtered out: ${nb.slug} ownerId=${nb.ownerId?.substring(0, 16) || 'none'}`) | |
| 58 | return match | |
| 92 | return nb.ownerId === auth.userId || (!nb.ownerId && auth.permissions && auth.permissions.admin) | |
| 59 | 93 | }) |
| 60 | console.log(`[Notebooks] After filter: ${notebooks.length}`) | |
| 61 | 94 | } else { |
| 62 | 95 | notebooks = [] |
| 63 | 96 | } |
| … | … | @@ -180,8 +213,8 @@ export function registerNotebookRoutes (router, server) { |
| 180 | 213 | // List notes (open read) |
| 181 | 214 | router.get('/api/notebooks/:notebook/notes', async (req, res) => { |
| 182 | 215 | const query = parseQuery(req.url) |
| 183 | const limit = query.limit ? parseInt(query.limit) : undefined | |
| 184 | const offset = query.offset ? parseInt(query.offset) : undefined | |
| 216 | const limit = Math.min(Math.max(1, parseInt(query.limit) || 50), 500) | |
| 217 | const offset = Math.max(0, parseInt(query.offset) || 0) | |
| 185 | 218 | const notes = await nm.listNotes(req.params.notebook, { limit, offset }) |
| 186 | 219 | json(res, { notes }) |
| 187 | 220 | }) |
| … | … | @@ -202,6 +235,7 @@ export function registerNotebookRoutes (router, server) { |
| 202 | 235 | |
| 203 | 236 | // Get note (open read) |
| 204 | 237 | router.get('/api/notebooks/:notebook/notes/:id', async (req, res) => { |
| 238 | if (!validateNoteId(res, req.params.id)) return | |
| 205 | 239 | const note = await nm.getNote(req.params.notebook, req.params.id) |
| 206 | 240 | if (!note) { |
| 207 | 241 | json(res, { error: 'Note not found', code: 'NOT_FOUND', status: 404 }, 404) |
| … | … | @@ -212,6 +246,7 @@ export function registerNotebookRoutes (router, server) { |
| 212 | 246 | |
| 213 | 247 | // Update note |
| 214 | 248 | router.patch('/api/notebooks/:notebook/notes/:id', async (req, res) => { |
| 249 | if (!validateNoteId(res, req.params.id)) return | |
| 215 | 250 | await requireAuth(req, server, 'write', req.params.notebook) |
| 216 | 251 | const body = await readJson(req) |
| 217 | 252 | const note = await nm.updateNote(req.params.notebook, req.params.id, { |
| … | … | @@ -226,6 +261,7 @@ export function registerNotebookRoutes (router, server) { |
| 226 | 261 | |
| 227 | 262 | // Delete note |
| 228 | 263 | router.delete('/api/notebooks/:notebook/notes/:id', async (req, res) => { |
| 264 | if (!validateNoteId(res, req.params.id)) return | |
| 229 | 265 | await requireAuth(req, server, 'write', req.params.notebook) |
| 230 | 266 | const result = await nm.deleteNote(req.params.notebook, req.params.id) |
| 231 | 267 | json(res, result) |
| … | … | @@ -235,6 +271,7 @@ export function registerNotebookRoutes (router, server) { |
| 235 | 271 | |
| 236 | 272 | // Move note |
| 237 | 273 | router.post('/api/notebooks/:notebook/notes/:id/move', async (req, res) => { |
| 274 | if (!validateNoteId(res, req.params.id)) return | |
| 238 | 275 | await requireAuth(req, server, 'write', req.params.notebook) |
| 239 | 276 | const body = await readJson(req) |
| 240 | 277 | await requireAuth(req, server, 'write', body.toNotebook) |
| … | … | @@ -295,11 +332,9 @@ export function registerNotebookRoutes (router, server) { |
| 295 | 332 | const snb = server.noteManager._getSharedNotebook(req.params.notebook) |
| 296 | 333 | if (drive.writable) { |
| 297 | 334 | await drive.put(drivePath, body) |
| 298 | console.log('[Media Upload] Drive:', drivePath, 'size:', body.length, 'notebook:', req.params.notebook) | |
| 299 | 335 | } else if (snb) { |
| 300 | 336 | const blobId = await snb.storeBlob(body) |
| 301 | 337 | await snb.addMedia(null, { hash: filename, mimeType: contentType, filename, size: body.length, blobId }, 'user') |
| 302 | console.log('[Media Upload] Blob:', filename, 'size:', body.length, 'blobId:', JSON.stringify(blobId), 'notebook:', req.params.notebook) | |
| 303 | 338 | } else { |
| 304 | 339 | json(res, { error: 'Notebook is read-only on this instance' }, 403) |
| 305 | 340 | return |
| … | … | @@ -313,10 +348,10 @@ export function registerNotebookRoutes (router, server) { |
| 313 | 348 | |
| 314 | 349 | // Serve media from notebook drive |
| 315 | 350 | router.get('/api/notebooks/:notebook/media/:filename', async (req, res) => { |
| 351 | if (!validateMediaFilename(res, req.params.filename)) return | |
| 316 | 352 | // No auth for media — public if you have the URL |
| 317 | 353 | const drive = await server.driveManager.getDrive(req.params.notebook) |
| 318 | 354 | if (!drive) { |
| 319 | console.log('[Media Serve] Drive not found for notebook:', req.params.notebook) | |
| 320 | 355 | res.statusCode = 404 |
| 321 | 356 | res.end('Not found') |
| 322 | 357 | return |
| … | … | @@ -13,6 +13,20 @@ import fs from 'bare-fs' |
| 13 | 13 | import path from 'bare-path' |
| 14 | 14 | import { escapeHtml } from '../response.js' |
| 15 | 15 | |
| 16 | // CSP: allow self + inline styles (TOUI editor needs them) + media from self | |
| 17 | const CSP_HEADER = [ | |
| 18 | "default-src 'self'", | |
| 19 | "script-src 'self'", | |
| 20 | "style-src 'self' 'unsafe-inline'", | |
| 21 | "img-src 'self' data: https:", | |
| 22 | "media-src 'self' https:", | |
| 23 | "font-src 'self'", | |
| 24 | "connect-src 'self'", | |
| 25 | "frame-ancestors 'none'", | |
| 26 | "base-uri 'self'", | |
| 27 | "form-action 'self'" | |
| 28 | ].join('; ') | |
| 29 | ||
| 16 | 30 | const MIME_TYPES = { |
| 17 | 31 | '.html': 'text/html; charset=utf-8', |
| 18 | 32 | '.css': 'text/css; charset=utf-8', |
| … | … | @@ -57,6 +71,11 @@ function serveFile (res, filePath) { |
| 57 | 71 | res.setHeader('Content-Type', contentType) |
| 58 | 72 | res.setHeader('X-Content-Type-Options', 'nosniff') |
| 59 | 73 | res.setHeader('Cache-Control', 'no-cache') |
| 74 | if (ext === '.html') { | |
| 75 | res.setHeader('Content-Security-Policy', CSP_HEADER) | |
| 76 | res.setHeader('X-Frame-Options', 'DENY') | |
| 77 | res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') | |
| 78 | } | |
| 60 | 79 | res.end(content) |
| 61 | 80 | } catch (err) { |
| 62 | 81 | res.statusCode = 404 |
| … | … | @@ -68,6 +87,16 @@ function serveFile (res, filePath) { |
| 68 | 87 | /** |
| 69 | 88 | * Render a simple HTML page (for public URL views) |
| 70 | 89 | */ |
| 90 | function sendHtml (res, html, status = 200) { | |
| 91 | res.statusCode = status | |
| 92 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 93 | res.setHeader('Content-Security-Policy', CSP_HEADER) | |
| 94 | res.setHeader('X-Frame-Options', 'DENY') | |
| 95 | res.setHeader('X-Content-Type-Options', 'nosniff') | |
| 96 | res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') | |
| 97 | res.end(html) | |
| 98 | } | |
| 99 | ||
| 71 | 100 | function renderPage (title, body) { |
| 72 | 101 | return `<!DOCTYPE html> |
| 73 | 102 | <html lang="en"> |
| … | … | @@ -120,6 +149,7 @@ export function registerWebRoutes (router, server) { |
| 120 | 149 | const html = getIndexHtml(webRoot) |
| 121 | 150 | res.statusCode = 200 |
| 122 | 151 | res.setHeader('Content-Type', 'text/html; charset=utf-8') |
| 152 | res.setHeader('Content-Security-Policy', CSP_HEADER) | |
| 123 | 153 | res.setHeader('X-Frame-Options', 'DENY') |
| 124 | 154 | res.setHeader('X-Content-Type-Options', 'nosniff') |
| 125 | 155 | res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') |
| … | … | @@ -204,9 +234,7 @@ export function registerPublicRoutes (router, server) { |
| 204 | 234 | const user = await server.authManager.identityRegistry.getUserByUsername(username) |
| 205 | 235 | if (!user) { |
| 206 | 236 | const html = renderPage('Not Found', '<h1>User not found</h1><p>No user with username <strong>@' + escapeHtml(username) + '</strong></p>') |
| 207 | res.statusCode = 404 | |
| 208 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 209 | res.end(html) | |
| 237 | sendHtml(res, html, 404) | |
| 210 | 238 | return |
| 211 | 239 | } |
| 212 | 240 | |
| … | … | @@ -229,10 +257,7 @@ export function registerPublicRoutes (router, server) { |
| 229 | 257 | } |
| 230 | 258 | } |
| 231 | 259 | |
| 232 | const html = renderPage('@' + username, body) | |
| 233 | res.statusCode = 200 | |
| 234 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 235 | res.end(html) | |
| 260 | sendHtml(res, renderPage('@' + username, body)) | |
| 236 | 261 | }) |
| 237 | 262 | |
| 238 | 263 | // GET /:username/:book — List notes in shared notebook |
| … | … | @@ -241,29 +266,17 @@ export function registerPublicRoutes (router, server) { |
| 241 | 266 | |
| 242 | 267 | const user = await server.authManager.identityRegistry.getUserByUsername(username) |
| 243 | 268 | if (!user) { |
| 244 | const html = renderPage('Not Found', '<h1>User not found</h1>') | |
| 245 | res.statusCode = 404 | |
| 246 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 247 | res.end(html) | |
| 248 | return | |
| 269 | return sendHtml(res, renderPage('Not Found', '<h1>User not found</h1>'), 404) | |
| 249 | 270 | } |
| 250 | 271 | |
| 251 | 272 | // Check drive ownership and sharing |
| 252 | 273 | const notebook = await server.noteManager.getNotebook(book) |
| 253 | 274 | if (!notebook || notebook.ownerId !== user.userId) { |
| 254 | const html = renderPage('Not Found', '<h1>Notebook not found</h1>') | |
| 255 | res.statusCode = 404 | |
| 256 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 257 | res.end(html) | |
| 258 | return | |
| 275 | return sendHtml(res, renderPage('Not Found', '<h1>Notebook not found</h1>'), 404) | |
| 259 | 276 | } |
| 260 | 277 | |
| 261 | 278 | if (notebook.visibility === 'private' || notebook.encrypted) { |
| 262 | const html = renderPage('Private', '<h1>Notebook is private</h1><p>This notebook has not been shared publicly.</p>') | |
| 263 | res.statusCode = 403 | |
| 264 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 265 | res.end(html) | |
| 266 | return | |
| 279 | return sendHtml(res, renderPage('Private', '<h1>Notebook is private</h1><p>This notebook has not been shared publicly.</p>'), 403) | |
| 267 | 280 | } |
| 268 | 281 | |
| 269 | 282 | const noteList = await server.noteManager.listNotes(book) |
| … | … | @@ -286,10 +299,7 @@ export function registerPublicRoutes (router, server) { |
| 286 | 299 | } |
| 287 | 300 | } |
| 288 | 301 | |
| 289 | const html = renderPage((notebook && notebook.name) || book, body) | |
| 290 | res.statusCode = 200 | |
| 291 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 292 | res.end(html) | |
| 302 | sendHtml(res, renderPage((notebook && notebook.name) || book, body)) | |
| 293 | 303 | }) |
| 294 | 304 | |
| 295 | 305 | // GET /:username/:book/:noteId — View single note |
| … | … | @@ -298,29 +308,17 @@ export function registerPublicRoutes (router, server) { |
| 298 | 308 | |
| 299 | 309 | const user = await server.authManager.identityRegistry.getUserByUsername(username) |
| 300 | 310 | if (!user) { |
| 301 | const html = renderPage('Not Found', '<h1>User not found</h1>') | |
| 302 | res.statusCode = 404 | |
| 303 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 304 | res.end(html) | |
| 305 | return | |
| 311 | return sendHtml(res, renderPage('Not Found', '<h1>User not found</h1>'), 404) | |
| 306 | 312 | } |
| 307 | 313 | |
| 308 | 314 | // Check notebook ownership and visibility |
| 309 | 315 | const notebook = await server.noteManager.getNotebook(book) |
| 310 | 316 | if (!notebook || notebook.ownerId !== user.userId) { |
| 311 | const html = renderPage('Not Found', '<h1>Note not found</h1>') | |
| 312 | res.statusCode = 404 | |
| 313 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 314 | res.end(html) | |
| 315 | return | |
| 317 | return sendHtml(res, renderPage('Not Found', '<h1>Note not found</h1>'), 404) | |
| 316 | 318 | } |
| 317 | 319 | |
| 318 | 320 | if (notebook.visibility === 'private' || notebook.encrypted) { |
| 319 | const html = renderPage('Private', '<h1>Notebook is private</h1>') | |
| 320 | res.statusCode = 403 | |
| 321 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 322 | res.end(html) | |
| 323 | return | |
| 321 | return sendHtml(res, renderPage('Private', '<h1>Notebook is private</h1>'), 403) | |
| 324 | 322 | } |
| 325 | 323 | |
| 326 | 324 | // Get note |
| … | … | @@ -341,15 +339,9 @@ export function registerPublicRoutes (router, server) { |
| 341 | 339 | body += '<div class="meta">' + new Date(note.updatedAt || note.createdAt).toLocaleDateString() + '</div>' |
| 342 | 340 | body += '<div class="markdown-body">' + escapeHtml(note.content || '') + '</div>' |
| 343 | 341 | |
| 344 | const html = renderPage(note.title || 'Note', body) | |
| 345 | res.statusCode = 200 | |
| 346 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 347 | res.end(html) | |
| 342 | sendHtml(res, renderPage(note.title || 'Note', body)) | |
| 348 | 343 | } catch (err) { |
| 349 | const html = renderPage('Not Found', '<h1>Note not found</h1>') | |
| 350 | res.statusCode = 404 | |
| 351 | res.setHeader('Content-Type', 'text/html; charset=utf-8') | |
| 352 | res.end(html) | |
| 344 | sendHtml(res, renderPage('Not Found', '<h1>Note not found</h1>'), 404) | |
| 353 | 345 | } |
| 354 | 346 | }) |
| 355 | 347 | } |
| … | … | @@ -9,7 +9,35 @@ window.renderMarkdown = function (src) { |
| 9 | 9 | |
| 10 | 10 | // Escape HTML |
| 11 | 11 | function esc (s) { |
| 12 | return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') | |
| 12 | return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 13 | } | |
| 14 | ||
| 15 | // Validate URL protocol — only allow safe protocols | |
| 16 | function safeUrl (url) { | |
| 17 | var decoded = url | |
| 18 | try { decoded = decodeURIComponent(url) } catch (e) {} | |
| 19 | var trimmed = decoded.trim().toLowerCase() | |
| 20 | if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.startsWith('vbscript:')) return '' | |
| 21 | return url | |
| 22 | } | |
| 23 | ||
| 24 | // Sanitize HTML media tag — strip all attributes except safe ones | |
| 25 | function sanitizeMediaTag (html) { | |
| 26 | var safeAttrs = /^\s*(src|controls|width|height|style|preload|poster|loop|muted|autoplay|alt|class|id)$/i | |
| 27 | return html.replace(/<(\w+)(\s[^>]*)>/g, function (match, tag, attrs) { | |
| 28 | var cleaned = attrs.replace(/\s*([\w-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/g, function (a, name, val) { | |
| 29 | if (!safeAttrs.test(name)) return '' | |
| 30 | // Also check src values for dangerous protocols | |
| 31 | if (name.toLowerCase() === 'src') { | |
| 32 | var unquoted = val.replace(/^["']|["']$/g, '') | |
| 33 | if (!safeUrl(unquoted)) return '' | |
| 34 | } | |
| 35 | return a | |
| 36 | }) | |
| 37 | // Strip bare event handlers like onerror onload etc | |
| 38 | cleaned = cleaned.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '') | |
| 39 | return '<' + tag + cleaned + '>' | |
| 40 | }) | |
| 13 | 41 | } |
| 14 | 42 | |
| 15 | 43 | var lines = src.split('\n') |
| … | … | @@ -40,7 +68,7 @@ window.renderMarkdown = function (src) { |
| 40 | 68 | for (var gi = 0; gi < galleryImages.length; gi++) { |
| 41 | 69 | var img = galleryImages[gi] |
| 42 | 70 | html.push('<div class="gallery-item" data-gallery="' + gid + '" data-index="' + gi + '">') |
| 43 | html.push('<img src="' + img.src + '" alt="' + esc(img.alt) + '" loading="lazy">') | |
| 71 | html.push('<img src="' + safeUrl(img.src) + '" alt="' + esc(img.alt) + '" loading="lazy">') | |
| 44 | 72 | if (img.alt) html.push('<div class="gallery-caption">' + esc(img.alt) + '</div>') |
| 45 | 73 | html.push('</div>') |
| 46 | 74 | } |
| … | … | @@ -137,7 +165,7 @@ window.renderMarkdown = function (src) { |
| 137 | 165 | continue |
| 138 | 166 | } |
| 139 | 167 | |
| 140 | // Pass through HTML media tags (video, audio) without escaping | |
| 168 | // Pass through HTML media tags (video, audio) with sanitization | |
| 141 | 169 | if (/^\s*<(video|audio)[\s>]/.test(line)) { |
| 142 | 170 | var tag = RegExp.$1 |
| 143 | 171 | var block = [line] |
| … | … | @@ -145,7 +173,7 @@ window.renderMarkdown = function (src) { |
| 145 | 173 | i++ |
| 146 | 174 | block.push(lines[i]) |
| 147 | 175 | } |
| 148 | html.push(block.join('\n')) | |
| 176 | html.push(sanitizeMediaTag(block.join('\n'))) | |
| 149 | 177 | continue |
| 150 | 178 | } |
| 151 | 179 | |
| … | … | @@ -199,14 +227,18 @@ window.renderMarkdown = function (src) { |
| 199 | 227 | var videoExts = /\.(mp4|webm|ogg|mov)$/i |
| 200 | 228 | text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_, alt, url) { |
| 201 | 229 | var src = (url.startsWith('/api/') && bp) ? bp + url : url |
| 230 | src = safeUrl(src) | |
| 202 | 231 | if (videoExts.test(url)) { |
| 203 | 232 | return '<video controls style="max-width:100%"><source src="' + src + '"></video>' |
| 204 | 233 | } |
| 205 | 234 | return '<img alt="' + alt + '" src="' + src + '">' |
| 206 | 235 | }) |
| 207 | 236 | |
| 208 | // Links [text](url) | |
| 209 | text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>') | |
| 237 | // Links [text](url) — sanitize href to prevent javascript: XSS | |
| 238 | text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, linkText, url) { | |
| 239 | var href = safeUrl(url) | |
| 240 | return '<a href="' + href + '">' + linkText + '</a>' | |
| 241 | }) | |
| 210 | 242 | |
| 211 | 243 | // Bold **text** or __text__ |
| 212 | 244 | text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') |