| … | … | @@ -49,14 +49,18 @@ import { registerMultiwriterRoutes } from './lib/routes/multiwriter.js' |
| 49 | 49 | import { json, notFound } from './lib/response.js' |
| 50 | 50 | |
| 51 | 51 | // Log version on startup for OTA verification |
| 52 | let _appVersion = '0.0.0' | |
| 52 | 53 | { |
| 53 | 54 | const _appDir = path.dirname(new URL(import.meta.url).pathname) |
| 54 | 55 | try { |
| 55 | 56 | const _pkg = JSON.parse(fs.readFileSync(path.join(_appDir, 'package.json'), 'utf-8')) |
| 56 | console.log(`[Hypernote] v${_pkg.version} (${_appDir})`) | |
| 57 | _appVersion = _pkg.version || _appVersion | |
| 58 | console.log(`[Hypernote] v${_appVersion} (${_appDir})`) | |
| 57 | 59 | } catch {} |
| 58 | 60 | } |
| 59 | 61 | |
| 62 | const DEBUG = typeof process !== 'undefined' && (process.env?.DEBUG === '1' || process.env?.DEBUG === 'true') | |
| 63 | ||
| 60 | 64 | // Catch unhandled rejections to prevent silent crashes |
| 61 | 65 | process.on('uncaughtException', (err) => { |
| 62 | 66 | console.error('[FATAL] Uncaught exception:', err.message, err.stack) |
| … | … | @@ -194,7 +198,7 @@ class HypernoteServer { |
| 194 | 198 | this.router.get('/api/health', async (req, res) => { |
| 195 | 199 | json(res, { |
| 196 | 200 | name: 'hypernote', |
| 197 | version: '0.2.0', | |
| 201 | version: _appVersion, | |
| 198 | 202 | description: 'P2P note-taking server' |
| 199 | 203 | }) |
| 200 | 204 | }) |
| … | … | @@ -204,7 +208,7 @@ class HypernoteServer { |
| 204 | 208 | const syncStatus = this.replicationManager.getStatus() |
| 205 | 209 | json(res, { |
| 206 | 210 | name: 'hypernote', |
| 207 | version: '0.2.0', | |
| 211 | version: _appVersion, | |
| 208 | 212 | storage: this.storage, |
| 209 | 213 | identity: { |
| 210 | 214 | hasKeypair: identity.hasKeypair, |
| … | … | @@ -244,31 +248,35 @@ class HypernoteServer { |
| 244 | 248 | return |
| 245 | 249 | } |
| 246 | 250 | |
| 247 | // Include diagnostic identity chain info | |
| 248 | const diag = {} | |
| 249 | try { | |
| 250 | const pubkey = req.headers['x-wirecore-publickey'] | |
| 251 | if (pubkey) { | |
| 252 | diag.browserPubkey = pubkey | |
| 253 | const pubkeyEntry = await server.authManager.identityRegistry.db.get(`pubkey:${pubkey}`) | |
| 254 | diag.pubkeyLookup = pubkeyEntry ? pubkeyEntry.value : null | |
| 255 | const identEntry = await server.authManager.identityRegistry.db.get(`identity:${pubkey}`) | |
| 256 | diag.identityRecord = identEntry ? { userId: identEntry.value.userId, method: identEntry.value.method } : null | |
| 257 | if (identEntry && identEntry.value.userId) { | |
| 258 | const userEntry = await server.authManager.identityRegistry.db.get(`user:${identEntry.value.userId}`) | |
| 259 | diag.userRecord = userEntry ? { userId: userEntry.value.userId, username: userEntry.value.username, admin: userEntry.value.globalPermissions?.includes('admin') } : 'NOT FOUND' | |
| 260 | } | |
| 261 | } | |
| 262 | } catch {} | |
| 263 | ||
| 264 | json(res, { | |
| 251 | const result = { | |
| 265 | 252 | authenticated: true, |
| 266 | 253 | userId: auth.userId, |
| 267 | 254 | method: auth.method, |
| 268 | 255 | username: auth.username || (auth.user && auth.user.username) || null, |
| 269 | permissions: auth.permissions, | |
| 270 | _diag: diag | |
| 271 | }) | |
| 256 | permissions: auth.permissions | |
| 257 | } | |
| 258 | ||
| 259 | // Include diagnostic identity chain info (admin only) | |
| 260 | if (auth.permissions && auth.permissions.admin) { | |
| 261 | const diag = {} | |
| 262 | try { | |
| 263 | const pubkey = req.headers['x-wirecore-publickey'] | |
| 264 | if (pubkey) { | |
| 265 | diag.browserPubkey = pubkey | |
| 266 | const pubkeyEntry = await server.authManager.identityRegistry.db.get(`pubkey:${pubkey}`) | |
| 267 | diag.pubkeyLookup = pubkeyEntry ? pubkeyEntry.value : null | |
| 268 | const identEntry = await server.authManager.identityRegistry.db.get(`identity:${pubkey}`) | |
| 269 | diag.identityRecord = identEntry ? { userId: identEntry.value.userId, method: identEntry.value.method } : null | |
| 270 | if (identEntry && identEntry.value.userId) { | |
| 271 | const userEntry = await server.authManager.identityRegistry.db.get(`user:${identEntry.value.userId}`) | |
| 272 | diag.userRecord = userEntry ? { userId: userEntry.value.userId, username: userEntry.value.username, admin: userEntry.value.globalPermissions?.includes('admin') } : 'NOT FOUND' | |
| 273 | } | |
| 274 | } | |
| 275 | } catch {} | |
| 276 | result._diag = diag | |
| 277 | } | |
| 278 | ||
| 279 | json(res, result) | |
| 272 | 280 | }) |
| 273 | 281 | |
| 274 | 282 | // Phase 2: Notebook + Note + Search routes |
| … | … | @@ -354,8 +362,8 @@ class HypernoteServer { |
| 354 | 362 | const mwKeyHex = mwStatus.registryKey ? (Buffer.isBuffer(mwStatus.registryKey) ? mwStatus.registryKey.toString('hex') : mwStatus.registryKey) : null |
| 355 | 363 | const localWriterKey = this.multiwriter.registry ? this.multiwriter.registry.localWriterKey : null |
| 356 | 364 | const lwkHex = localWriterKey ? (Buffer.isBuffer(localWriterKey) ? localWriterKey.toString('hex') : localWriterKey) : null |
| 357 | console.log(`[Multiwriter] Registry key: ${mwKeyHex ? mwKeyHex.substring(0, 16) + '...' : 'none'}`) | |
| 358 | console.log(`[Multiwriter] Local writer key: ${lwkHex || 'none'}`) | |
| 365 | if (DEBUG) console.log(`[Multiwriter] Registry key: ${mwKeyHex ? mwKeyHex.substring(0, 16) + '...' : 'none'}`) | |
| 366 | if (DEBUG) console.log(`[Multiwriter] Local writer key: ${lwkHex || 'none'}`) | |
| 359 | 367 | console.log(`[Multiwriter] Shared notebooks: ${mwStatus.sharedNotebooks}`) |
| 360 | 368 | |
| 361 | 369 | // Start P2P replication in background (don't block HTTP start) |
| … | … | @@ -448,7 +456,7 @@ class HypernoteServer { |
| 448 | 456 | const duplicates = users.slice(1) |
| 449 | 457 | |
| 450 | 458 | for (const dup of duplicates) { |
| 451 | console.log(`[Dedup] Merging duplicate user ${dup.userId.substring(0, 16)} into ${primary.userId.substring(0, 16)} (${primary.username || 'no name'}) for pubkey ${pubkey.substring(0, 20)}...`) | |
| 459 | if (DEBUG) console.log(`[Dedup] Merging duplicate user ${dup.userId.substring(0, 16)} into ${primary.userId.substring(0, 16)} (${primary.username || 'no name'}) for pubkey ${pubkey.substring(0, 20)}...`) | |
| 452 | 460 | |
| 453 | 461 | // Merge permissions (union) |
| 454 | 462 | primary.globalPermissions = [...new Set([...primary.globalPermissions, ...dup.globalPermissions])] |
| … | … | @@ -485,7 +493,7 @@ class HypernoteServer { |
| 485 | 493 | |
| 486 | 494 | primary.updatedAt = Date.now() |
| 487 | 495 | await db.put(`user:${primary.userId}`, primary) |
| 488 | console.log(`[Dedup] Fixed: identity ${pubkey.substring(0, 20)}... → user ${primary.userId.substring(0, 16)} (${primary.username})`) | |
| 496 | if (DEBUG) console.log(`[Dedup] Fixed: identity ${pubkey.substring(0, 20)}... → user ${primary.userId.substring(0, 16)} (${primary.username})`) | |
| 489 | 497 | } |
| 490 | 498 | } catch (err) { |
| 491 | 499 | console.error('[Dedup] Failed:', err.message) |
| … | … | @@ -507,7 +515,7 @@ class HypernoteServer { |
| 507 | 515 | if (identEntry && identEntry.value) { |
| 508 | 516 | identEntry.value.userId = repair.correctUserId |
| 509 | 517 | await this.authManager.identityRegistry.db.put(`identity:${repair.pubkey}`, identEntry.value) |
| 510 | console.log(`[Repair] Fixed identity ${repair.pubkey.substring(0, 16)}... → user ${repair.correctUserId.substring(0, 16)}`) | |
| 518 | if (DEBUG) console.log(`[Repair] Fixed identity ${repair.pubkey.substring(0, 16)}... → user ${repair.correctUserId.substring(0, 16)}`) | |
| 511 | 519 | } |
| 512 | 520 | // Fix pubkey reverse lookup |
| 513 | 521 | await this.authManager.identityRegistry.db.put(`pubkey:${repair.pubkey}`, { identityId: repair.pubkey }) |
| … | … | @@ -677,6 +685,12 @@ class HypernoteServer { |
| 677 | 685 | } |
| 678 | 686 | }) |
| 679 | 687 | |
| 688 | // Request timeout — prevent stalled connections from accumulating | |
| 689 | server.setTimeout(120000) // 2 minutes | |
| 690 | server.on('timeout', (socket) => { | |
| 691 | socket.destroy() | |
| 692 | }) | |
| 693 | ||
| 680 | 694 | const host = this.host || 'localhost' |
| 681 | 695 | server.listen(this.port, host, () => { |
| 682 | 696 | console.log(`[Hypernote] Server listening on http://${host}:${this.port}`) |
| … | … | @@ -822,7 +836,7 @@ if (command === 'serve' || !command) { |
| 822 | 836 | await authDb.ready() |
| 823 | 837 | |
| 824 | 838 | const { AuthManager } = await import('./lib/auth.js') |
| 825 | const authManager = new AuthManager(authDb) | |
| 839 | const authManager = new AuthManager({ registry: authDb }) | |
| 826 | 840 | |
| 827 | 841 | const result = await authManager.createToken({ |
| 828 | 842 | username: 'admin', |
| … | … | @@ -43,7 +43,8 @@ export class AuthManager { |
| 43 | 43 | 'auth': { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 }, |
| 44 | 44 | 'api': { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 }, |
| 45 | 45 | 'notes': { tokens: 5000, refillRate: 500, refillInterval: 60 * 1000 }, |
| 46 | 'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 } | |
| 46 | 'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 }, | |
| 47 | 'media-upload': { tokens: 50, refillRate: 10, refillInterval: 60 * 1000 } | |
| 47 | 48 | }, |
| 48 | 49 | cacheTimeout: options.rateLimitCacheTimeout || 10 * 1000 // 10 seconds |
| 49 | 50 | }) |
| … | … | @@ -188,7 +189,7 @@ export class AuthManager { |
| 188 | 189 | method: authResult.method, |
| 189 | 190 | permissions: this.formatUserPermissions(user, null), |
| 190 | 191 | user, |
| 191 | verified: true | |
| 192 | verified: authResult.verified !== false | |
| 192 | 193 | } |
| 193 | 194 | } catch (err) { |
| 194 | 195 | console.error(`[Auth] Provider ${provider.name} threw:`, err.message) |
| … | … | @@ -250,7 +251,10 @@ export class AuthManager { |
| 250 | 251 | * @returns {boolean} |
| 251 | 252 | */ |
| 252 | 253 | hasNotebookAccess(auth, notebookName, action = 'read') { |
| 253 | if (!auth || !auth.verified) return false | |
| 254 | if (!auth) return false | |
| 255 | ||
| 256 | // Unverified (legacy) auth can only read — block write/admin | |
| 257 | if (!auth.verified && action !== 'read') return false | |
| 254 | 258 | |
| 255 | 259 | const perms = auth.permissions |
| 256 | 260 |
| … | … | @@ -32,6 +32,7 @@ export class MultiwriterManager { |
| 32 | 32 | this._identityRegistry = null |
| 33 | 33 | this._pendingCallbackTokens = new Map() // token -> { createdAt } |
| 34 | 34 | this._lock = mutexify() // serialize state transitions |
| 35 | this._identityLock = mutexify() // serialize identity sync operations | |
| 35 | 36 | this._addedNbWriters = new Set() |
| 36 | 37 | } |
| 37 | 38 | |
| … | … | @@ -264,6 +265,14 @@ export class MultiwriterManager { |
| 264 | 265 | |
| 265 | 266 | async _syncMirrorUnsafe () { |
| 266 | 267 | |
| 268 | // Clean up expired callback tokens | |
| 269 | const now = Date.now() | |
| 270 | for (const [token, entry] of this._pendingCallbackTokens) { | |
| 271 | if (now - entry.createdAt > CALLBACK_TOKEN_TTL) { | |
| 272 | this._pendingCallbackTokens.delete(token) | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 267 | 276 | // 0. Update Autobase to get latest from peers |
| 268 | 277 | try { |
| 269 | 278 | await this._registry.base.update() |
| … | … | @@ -510,6 +519,15 @@ export class MultiwriterManager { |
| 510 | 519 | } |
| 511 | 520 | |
| 512 | 521 | async _syncIdentities () { |
| 522 | const release = await this._identityLock() | |
| 523 | try { | |
| 524 | await this._syncIdentitiesUnsafe() | |
| 525 | } finally { | |
| 526 | release() | |
| 527 | } | |
| 528 | } | |
| 529 | ||
| 530 | async _syncIdentitiesUnsafe () { | |
| 513 | 531 | if (!this._identityRegistry) { |
| 514 | 532 | console.log('[Mirror] _syncIdentities: no identity registry, skipping') |
| 515 | 533 | return |
| … | … | @@ -37,7 +37,9 @@ export class NoteManager { |
| 37 | 37 | this.crypto = crypto || null // NotebookCrypto instance (optional) |
| 38 | 38 | this.linkManager = linkManager || null // LinkManager instance (optional, set after construction) |
| 39 | 39 | this.multiwriter = multiwriter || null // MultiwriterManager instance (optional, set after construction) |
| 40 | this._keyCache = new Map() // slug → Buffer (unlocked notebook keys) | |
| 40 | this._keyCache = new Map() // slug → { key: Buffer, unlockedAt: number } | |
| 41 | this._keyCacheTTL = 30 * 60 * 1000 // 30 minutes | |
| 42 | this._keyCacheCleanupInterval = setInterval(() => this._evictExpiredKeys(), 5 * 60 * 1000) | |
| 41 | 43 | } |
| 42 | 44 | |
| 43 | 45 | /** |
| … | … | @@ -74,22 +76,41 @@ export class NoteManager { |
| 74 | 76 | async unlockNotebook (slug, password) { |
| 75 | 77 | if (!this.crypto) throw new Error('Encryption not configured') |
| 76 | 78 | const key = await this.crypto.getNotebookKey(slug, password) |
| 77 | this._keyCache.set(slug, key) | |
| 79 | this._keyCache.set(slug, { key, unlockedAt: Date.now() }) | |
| 78 | 80 | return true |
| 79 | 81 | } |
| 80 | 82 | |
| 81 | 83 | /** |
| 82 | * Check if a notebook's key is cached (unlocked). | |
| 84 | * Check if a notebook's key is cached (unlocked) and not expired. | |
| 83 | 85 | */ |
| 84 | 86 | isUnlocked (slug) { |
| 85 | return this._keyCache.has(slug) | |
| 87 | const entry = this._keyCache.get(slug) | |
| 88 | if (!entry) return false | |
| 89 | if (Date.now() - entry.unlockedAt > this._keyCacheTTL) { | |
| 90 | this._keyCache.delete(slug) | |
| 91 | return false | |
| 92 | } | |
| 93 | return true | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * Get the cached key for a slug, or null if expired/missing. | |
| 98 | */ | |
| 99 | _getCachedKey (slug) { | |
| 100 | const entry = this._keyCache.get(slug) | |
| 101 | if (!entry) return null | |
| 102 | if (Date.now() - entry.unlockedAt > this._keyCacheTTL) { | |
| 103 | this._keyCache.delete(slug) | |
| 104 | return null | |
| 105 | } | |
| 106 | return entry.key | |
| 86 | 107 | } |
| 87 | 108 | |
| 88 | 109 | /** |
| 89 | 110 | * Set a notebook key directly (e.g. after enableEncryption). |
| 90 | 111 | */ |
| 91 | 112 | cacheKey (slug, key) { |
| 92 | this._keyCache.set(slug, key) | |
| 113 | this._keyCache.set(slug, { key, unlockedAt: Date.now() }) | |
| 93 | 114 | } |
| 94 | 115 | |
| 95 | 116 | /** |
| … | … | @@ -99,6 +120,18 @@ export class NoteManager { |
| 99 | 120 | this._keyCache.delete(slug) |
| 100 | 121 | } |
| 101 | 122 | |
| 123 | /** | |
| 124 | * Evict expired keys from the cache. | |
| 125 | */ | |
| 126 | _evictExpiredKeys () { | |
| 127 | const now = Date.now() | |
| 128 | for (const [slug, entry] of this._keyCache) { | |
| 129 | if (now - entry.unlockedAt > this._keyCacheTTL) { | |
| 130 | this._keyCache.delete(slug) | |
| 131 | } | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 102 | 135 | // ==================== SLUG HELPERS ==================== |
| 103 | 136 | |
| 104 | 137 | /** |
| … | … | @@ -145,7 +178,10 @@ export class NoteManager { |
| 145 | 178 | } |
| 146 | 179 | |
| 147 | 180 | /** |
| 148 | * Ensure a note has a slug field. If missing, generate from title and persist. | |
| 181 | * Ensure a note has a slug field. If missing, generate from title. | |
| 182 | * Intentionally does NOT persist here — slug is persisted lazily via | |
| 183 | * listNotes() (see needsPersist flag at line ~704) to avoid writes | |
| 184 | * on every single note read. This is a deliberate design choice. | |
| 149 | 185 | */ |
| 150 | 186 | _ensureSlug (note, notebook) { |
| 151 | 187 | if (note.slug) return note |
| … | … | @@ -239,7 +275,7 @@ export class NoteManager { |
| 239 | 275 | * Encrypt title + content of a note if the notebook is encrypted. |
| 240 | 276 | */ |
| 241 | 277 | _encryptNote (note, slug) { |
| 242 | const key = this._keyCache.get(slug) | |
| 278 | const key = this._getCachedKey(slug) | |
| 243 | 279 | if (!key || !this.crypto) return note |
| 244 | 280 | |
| 245 | 281 | return { |
| … | … | @@ -256,7 +292,7 @@ export class NoteManager { |
| 256 | 292 | _decryptNote (note, slug) { |
| 257 | 293 | if (!note.encrypted) return note |
| 258 | 294 | |
| 259 | const key = this._keyCache.get(slug) | |
| 295 | const key = this._getCachedKey(slug) | |
| 260 | 296 | if (!key || !this.crypto) return note |
| 261 | 297 | |
| 262 | 298 | return { |
| … | … | @@ -477,29 +513,39 @@ export class NoteManager { |
| 477 | 513 | // Create new drive |
| 478 | 514 | const newDrive = await this.driveManager.createDrive(newSlug) |
| 479 | 515 | |
| 480 | // Copy metadata with new slug | |
| 481 | const now = Date.now() | |
| 482 | const newMeta = { ...oldMeta, slug: newSlug, updatedAt: now } | |
| 483 | delete newMeta.isLocal | |
| 484 | delete newMeta.driveKey | |
| 485 | await newDrive.put('/metadata.json', Buffer.from(JSON.stringify(newMeta, null, 2))) | |
| 486 | ||
| 487 | // Copy all notes | |
| 488 | const oldDrive = await this._getDrive(oldSlug) | |
| 489 | 516 | try { |
| 490 | const noteFiles = await oldDrive.readdir('/notes') | |
| 491 | for await (const file of noteFiles) { | |
| 492 | const buf = await oldDrive.get(`/notes/${file}`) | |
| 493 | if (buf) { | |
| 494 | await newDrive.put(`/notes/${file}`, buf) | |
| 517 | // Copy metadata with new slug | |
| 518 | const now = Date.now() | |
| 519 | const newMeta = { ...oldMeta, slug: newSlug, updatedAt: now } | |
| 520 | delete newMeta.isLocal | |
| 521 | delete newMeta.driveKey | |
| 522 | await newDrive.put('/metadata.json', Buffer.from(JSON.stringify(newMeta, null, 2))) | |
| 523 | ||
| 524 | // Copy all notes | |
| 525 | const oldDrive = await this._getDrive(oldSlug) | |
| 526 | try { | |
| 527 | const noteFiles = await oldDrive.readdir('/notes') | |
| 528 | for await (const file of noteFiles) { | |
| 529 | const buf = await oldDrive.get(`/notes/${file}`) | |
| 530 | if (buf) { | |
| 531 | await newDrive.put(`/notes/${file}`, buf) | |
| 532 | } | |
| 495 | 533 | } |
| 534 | } catch { | |
| 535 | // no notes dir | |
| 496 | 536 | } |
| 497 | } catch { | |
| 498 | // no notes dir | |
| 499 | } | |
| 500 | 537 | |
| 501 | // Delete old notebook | |
| 502 | await this.deleteNotebook(oldSlug) | |
| 538 | // Delete old notebook | |
| 539 | await this.deleteNotebook(oldSlug) | |
| 540 | } catch (err) { | |
| 541 | // Rollback: clean up the partially-created new drive | |
| 542 | try { | |
| 543 | await this.driveManager.deleteDrive(newSlug) | |
| 544 | } catch (cleanupErr) { | |
| 545 | console.error('[NoteManager] Rollback cleanup failed for ' + newSlug + ':', cleanupErr.message) | |
| 546 | } | |
| 547 | throw err | |
| 548 | } | |
| 503 | 549 | |
| 504 | 550 | // Return with transient fields |
| 505 | 551 | newMeta.isLocal = true |
| … | … | @@ -1000,9 +1046,12 @@ export class NoteManager { |
| 1000 | 1046 | * Get recent notes across all public notebooks |
| 1001 | 1047 | * Returns notes with notebook metadata attached |
| 1002 | 1048 | */ |
| 1049 | // TODO: streaming pagination — currently loads all notes into memory | |
| 1003 | 1050 | async recentNotes ({ limit = 20 } = {}) { |
| 1004 | 1051 | const allNotes = [] |
| 1005 | 1052 | const notebooks = await this.listNotebooks() |
| 1053 | // Collect at most limit*2 notes per notebook to reduce memory usage | |
| 1054 | const perNotebookCap = limit * 2 | |
| 1006 | 1055 | |
| 1007 | 1056 | for (const nb of notebooks) { |
| 1008 | 1057 | // Skip private and encrypted notebooks |
| … | … | @@ -1013,7 +1062,9 @@ export class NoteManager { |
| 1013 | 1062 | if (snb) { |
| 1014 | 1063 | try { |
| 1015 | 1064 | const notes = await snb.listNotes() |
| 1065 | let count = 0 | |
| 1016 | 1066 | for (const note of notes) { |
| 1067 | if (count >= perNotebookCap) break | |
| 1017 | 1068 | try { |
| 1018 | 1069 | const doc = await snb.reconstructDoc(note.noteId) |
| 1019 | 1070 | const content = doc.getText('content').toString() |
| … | … | @@ -1029,6 +1080,7 @@ export class NoteManager { |
| 1029 | 1080 | notebookName: nb.name, |
| 1030 | 1081 | owner: nb.ownerId || null |
| 1031 | 1082 | }) |
| 1083 | count++ | |
| 1032 | 1084 | } catch {} |
| 1033 | 1085 | } |
| 1034 | 1086 | } catch {} |
| … | … | @@ -1045,7 +1097,9 @@ export class NoteManager { |
| 1045 | 1097 | continue |
| 1046 | 1098 | } |
| 1047 | 1099 | |
| 1100 | let count = 0 | |
| 1048 | 1101 | for await (const file of files) { |
| 1102 | if (count >= perNotebookCap) break | |
| 1049 | 1103 | if (!file.endsWith('.json')) continue |
| 1050 | 1104 | const buf = await drive.get(`/notes/${file}`) |
| 1051 | 1105 | if (!buf) continue |
| … | … | @@ -1063,6 +1117,7 @@ export class NoteManager { |
| 1063 | 1117 | notebookName: nb.name, |
| 1064 | 1118 | owner: nb.ownerId || null |
| 1065 | 1119 | }) |
| 1120 | count++ | |
| 1066 | 1121 | } |
| 1067 | 1122 | } |
| 1068 | 1123 | |
| … | … | @@ -1072,7 +1127,8 @@ export class NoteManager { |
| 1072 | 1127 | |
| 1073 | 1128 | // ==================== SEARCH ==================== |
| 1074 | 1129 | |
| 1075 | async searchNotes (query, { notebook } = {}) { | |
| 1130 | // TODO: streaming pagination — currently loads all matching notes into memory | |
| 1131 | async searchNotes (query, { notebook, maxResults = 100 } = {}) { | |
| 1076 | 1132 | if (!query || typeof query !== 'string') { |
| 1077 | 1133 | throw new ValidationError('Search query is required', 'query') |
| 1078 | 1134 | } |
| … | … | @@ -1082,6 +1138,8 @@ export class NoteManager { |
| 1082 | 1138 | const notebooks = notebook ? [notebook] : await this._listNotebookSlugs() |
| 1083 | 1139 | |
| 1084 | 1140 | for (const slug of notebooks) { |
| 1141 | if (results.length >= maxResults) break | |
| 1142 | ||
| 1085 | 1143 | const drive = await this.driveManager.getDrive(slug) |
| 1086 | 1144 | if (!drive) continue |
| 1087 | 1145 | |
| … | … | @@ -1091,6 +1149,7 @@ export class NoteManager { |
| 1091 | 1149 | try { |
| 1092 | 1150 | const notes = await snb.listNotes() |
| 1093 | 1151 | for (const note of notes) { |
| 1152 | if (results.length >= maxResults) break | |
| 1094 | 1153 | try { |
| 1095 | 1154 | const doc = await snb.reconstructDoc(note.noteId) |
| 1096 | 1155 | const content = doc.getText('content').toString() |
| … | … | @@ -1125,6 +1184,7 @@ export class NoteManager { |
| 1125 | 1184 | } |
| 1126 | 1185 | |
| 1127 | 1186 | for await (const file of files) { |
| 1187 | if (results.length >= maxResults) break | |
| 1128 | 1188 | if (!file.endsWith('.json')) continue |
| 1129 | 1189 | const buf = await drive.get(`/notes/${file}`) |
| 1130 | 1190 | if (!buf) continue |
| … | … | @@ -1132,7 +1192,7 @@ export class NoteManager { |
| 1132 | 1192 | const raw = JSON.parse(buf.toString()) |
| 1133 | 1193 | |
| 1134 | 1194 | // For encrypted notebooks without unlocked key, only search tags |
| 1135 | if (raw.encrypted && !this._keyCache.has(slug)) { | |
| 1195 | if (raw.encrypted && !this.isUnlocked(slug)) { | |
| 1136 | 1196 | const tagMatch = raw.tags && raw.tags.some(t => t.toLowerCase().includes(q)) |
| 1137 | 1197 | if (tagMatch) { |
| 1138 | 1198 | results.push({ |
| … | … | @@ -14,7 +14,7 @@ export class ReplicationManager { |
| 14 | 14 | this.driveManager = driveManager |
| 15 | 15 | this.swarm = null |
| 16 | 16 | this._discoveries = new Map() // slug → discovery handle |
| 17 | this._peerCount = 0 | |
| 17 | this._connections = new Set() | |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 20 | /** |
| … | … | @@ -25,9 +25,9 @@ export class ReplicationManager { |
| 25 | 25 | |
| 26 | 26 | this.swarm.on('connection', (conn, info) => { |
| 27 | 27 | this.store.replicate(conn) |
| 28 | this._peerCount++ | |
| 29 | conn.on('close', () => { this._peerCount-- }) | |
| 30 | conn.on('error', () => { this._peerCount-- }) | |
| 28 | this._connections.add(conn) | |
| 29 | conn.on('close', () => { this._connections.delete(conn) }) | |
| 30 | conn.on('error', () => {}) // error is followed by close, which handles cleanup | |
| 31 | 31 | }) |
| 32 | 32 | } |
| 33 | 33 | |
| … | … | @@ -39,7 +39,7 @@ export class ReplicationManager { |
| 39 | 39 | await this.swarm.destroy() |
| 40 | 40 | this.swarm = null |
| 41 | 41 | this._discoveries.clear() |
| 42 | this._peerCount = 0 | |
| 42 | this._connections.clear() | |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | 45 | /** |
| … | … | @@ -103,7 +103,7 @@ export class ReplicationManager { |
| 103 | 103 | getStatus () { |
| 104 | 104 | return { |
| 105 | 105 | swarmActive: this.swarm !== null, |
| 106 | peersConnected: this._peerCount, | |
| 106 | peersConnected: this._connections.size, | |
| 107 | 107 | announced: [...this._discoveries.keys()] |
| 108 | 108 | } |
| 109 | 109 | } |
| … | … | @@ -63,7 +63,7 @@ export function forbidden(res, message = 'Forbidden') { |
| 63 | 63 | */ |
| 64 | 64 | export function unauthorized(res, message = 'Unauthorized') { |
| 65 | 65 | res.statusCode = 401 |
| 66 | res.setHeader('WWW-Authenticate', 'Bearer realm="Pear Git"') | |
| 66 | res.setHeader('WWW-Authenticate', 'Bearer realm="Hypernote"') | |
| 67 | 67 | res.setHeader('Content-Type', 'application/json') |
| 68 | 68 | res.end(JSON.stringify({ error: message })) |
| 69 | 69 | } |
| … | … | @@ -0,0 +1,20 @@ |
| 1 | /** | |
| 2 | * Shared auth helpers for route handlers. | |
| 3 | */ | |
| 4 | ||
| 5 | import { UnauthorizedError, ForbiddenError } from '../errors.js' | |
| 6 | ||
| 7 | /** | |
| 8 | * Auth helper — authenticate + check permission, throw on failure. | |
| 9 | * @param {Object} req - HTTP request | |
| 10 | * @param {Object} server - HypernoteServer instance | |
| 11 | * @param {string} action - 'read', 'write', or 'admin' | |
| 12 | * @param {string} [notebook] - Notebook slug (optional) | |
| 13 | * @returns {Promise<Object>} Auth result | |
| 14 | */ | |
| 15 | export async function requireAuth (req, server, action, notebook) { | |
| 16 | const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, notebook) | |
| 17 | if (!auth) throw new UnauthorizedError('Authentication required') | |
| 18 | if (!authorized) throw new ForbiddenError(reason, action) | |
| 19 | return auth | |
| 20 | } |
| … | … | @@ -194,7 +194,7 @@ export class Router { |
| 194 | 194 | |
| 195 | 195 | // Add WWW-Authenticate header for 401 responses |
| 196 | 196 | if (status === 401) { |
| 197 | res.setHeader('WWW-Authenticate', 'Bearer realm="Pear Git"') | |
| 197 | res.setHeader('WWW-Authenticate', 'Bearer realm="Hypernote"') | |
| 198 | 198 | } |
| 199 | 199 | |
| 200 | 200 | // Add Retry-After header for rate limit errors |
| … | … | @@ -15,16 +15,7 @@ |
| 15 | 15 | |
| 16 | 16 | import { json, error, readJson } from '../response.js' |
| 17 | 17 | import { UnauthorizedError, ForbiddenError } from '../errors.js' |
| 18 | ||
| 19 | /** | |
| 20 | * Auth helper — authenticate + check permission, throw on failure | |
| 21 | */ | |
| 22 | async function requireAuth (req, server, action, notebook) { | |
| 23 | const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, notebook) | |
| 24 | if (!auth) throw new UnauthorizedError('Authentication required') | |
| 25 | if (!authorized) throw new ForbiddenError(reason, action) | |
| 26 | return auth | |
| 27 | } | |
| 18 | import { requireAuth } from './auth-helpers.js' | |
| 28 | 19 | |
| 29 | 20 | /** |
| 30 | 21 | * Register all multi-writer routes |
| … | … | @@ -5,7 +5,7 @@ |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { json, readJson } from '../response.js' |
| 8 | import { UnauthorizedError, ForbiddenError } from '../errors.js' | |
| 8 | import { requireAuth } from './auth-helpers.js' | |
| 9 | 9 | |
| 10 | 10 | /** |
| 11 | 11 | * Parse query string from raw request URL |
| … | … | @@ -82,16 +82,6 @@ function validateMediaFilename (res, filename) { |
| 82 | 82 | return true |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | /** | |
| 86 | * Auth helper — authenticate + check permission, throw on failure | |
| 87 | */ | |
| 88 | async function requireAuth (req, server, action, notebook) { | |
| 89 | const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, notebook) | |
| 90 | if (!auth) throw new UnauthorizedError('Authentication required') | |
| 91 | if (!authorized) throw new ForbiddenError(reason, action) | |
| 92 | return auth | |
| 93 | } | |
| 94 | ||
| 95 | 85 | /** |
| 96 | 86 | * Register all notebook, note, and search routes |
| 97 | 87 | */ |
| … | … | @@ -107,12 +97,23 @@ export function registerNotebookRoutes (router, server) { |
| 107 | 97 | |
| 108 | 98 | const query = parseQuery(req.url) |
| 109 | 99 | |
| 100 | // Attempt auth (non-throwing) to determine visibility | |
| 101 | let auth = null | |
| 102 | try { | |
| 103 | auth = await server.authManager.authenticate(req, { skipRateLimit: true }) | |
| 104 | } catch {} | |
| 105 | ||
| 106 | // Hide private and encrypted notebooks from non-admin users | |
| 107 | const isAdmin = auth && auth.permissions && auth.permissions.admin | |
| 108 | if (!isAdmin) { | |
| 109 | notebooks = notebooks.filter(nb => nb.visibility !== 'private' && !nb.encrypted) | |
| 110 | } | |
| 111 | ||
| 110 | 112 | // Filter to current user's notebooks |
| 111 | 113 | if (query.mine === 'true') { |
| 112 | const auth = await server.authManager.authenticate(req) | |
| 113 | 114 | if (auth && auth.userId) { |
| 114 | 115 | notebooks = notebooks.filter(nb => { |
| 115 | return nb.ownerId === auth.userId || (!nb.ownerId && auth.permissions && auth.permissions.admin) | |
| 116 | return nb.ownerId === auth.userId || (!nb.ownerId && isAdmin) | |
| 116 | 117 | }) |
| 117 | 118 | } else { |
| 118 | 119 | notebooks = [] |
| … | … | @@ -125,12 +126,11 @@ export function registerNotebookRoutes (router, server) { |
| 125 | 126 | if (ownerUser) { |
| 126 | 127 | // Check if this user has admin permissions (to include unowned notebooks) |
| 127 | 128 | const userPerms = ownerUser.globalPermissions || [] |
| 128 | const isAdmin = userPerms.includes('admin') | |
| 129 | const ownerIsAdmin = userPerms.includes('admin') | |
| 129 | 130 | notebooks = notebooks.filter(nb => { |
| 130 | if (nb.visibility === 'private' || nb.encrypted) return false | |
| 131 | 131 | if (nb.ownerId === ownerUser.userId) return true |
| 132 | 132 | // Include unowned notebooks for the admin user |
| 133 | if (isAdmin && !nb.ownerId) return true | |
| 133 | if (ownerIsAdmin && !nb.ownerId) return true | |
| 134 | 134 | return false |
| 135 | 135 | }) |
| 136 | 136 | } else { |
| … | … | @@ -330,6 +330,13 @@ export function registerNotebookRoutes (router, server) { |
| 330 | 330 | // Upload image/media to notebook drive |
| 331 | 331 | router.post('/api/notebooks/:notebook/media', async (req, res) => { |
| 332 | 332 | await requireAuth(req, server, 'write', req.params.notebook) |
| 333 | // Apply media-specific rate limit | |
| 334 | const clientIp = server.authManager.getClientIp(req) | |
| 335 | const rateLimit = await server.authManager.rateLimiter.check(clientIp, 'media-upload') | |
| 336 | if (!rateLimit.allowed) { | |
| 337 | json(res, { error: 'Media upload rate limit exceeded', retryAfter: rateLimit.retryAfter }, 429) | |
| 338 | return | |
| 339 | } | |
| 333 | 340 | |
| 334 | 341 | const maxMediaSize = server.maxMediaSize || 50 * 1024 * 1024 // 50MB default |
| 335 | 342 | const chunks = [] |
| … | … | @@ -5,20 +5,11 @@ |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { json, readJson } from '../response.js' |
| 8 | import { UnauthorizedError, ForbiddenError, ValidationError, NotFoundError } from '../errors.js' | |
| 8 | import { ValidationError, NotFoundError } from '../errors.js' | |
| 9 | import { requireAuth } from './auth-helpers.js' | |
| 9 | 10 | |
| 10 | 11 | const SHARE_PREFIX = 'share:' |
| 11 | 12 | |
| 12 | /** | |
| 13 | * Auth helper — authenticate + check permission, throw on failure | |
| 14 | */ | |
| 15 | async function requireAuth (req, server, action, notebook) { | |
| 16 | const { authorized, auth, reason } = await server.authManager.authorizeRequest(req, action, notebook) | |
| 17 | if (!auth) throw new UnauthorizedError('Authentication required') | |
| 18 | if (!authorized) throw new ForbiddenError(reason, action) | |
| 19 | return auth | |
| 20 | } | |
| 21 | ||
| 22 | 13 | /** |
| 23 | 14 | * Register sharing and encryption routes |
| 24 | 15 | */ |
| … | … | @@ -8,6 +8,9 @@ |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | window.HnIdentity = (function () { |
| 11 | // Security note: Private key is stored in localStorage, which is inherently | |
| 12 | // accessible to same-origin scripts. CSP headers mitigate XSS risk. | |
| 13 | // This is the standard trade-off for browser-only ed25519 identity. | |
| 11 | 14 | var KEYPAIR_KEY = 'hn-keypair' |
| 12 | 15 | var PUBKEY_KEY = 'hn-pubkey' |
| 13 | 16 | var USERNAME_KEY = 'hn-username' |
| … | … | @@ -4,6 +4,10 @@ |
| 4 | 4 | * blockquotes, hr, tables, wiki-links [[...]], gallery blocks |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | // Security: All inline text is HTML-escaped via esc(), URLs are validated via safeUrl() | |
| 8 | // (blocks javascript:/data:/blob:/vbscript:), and HTML media tags are sanitized via | |
| 9 | // sanitizeMediaTag() which strips all but whitelisted attributes and event handlers. | |
| 10 | // TODO: Consider adding DOMPurify as a final sanitization pass for defense-in-depth. | |
| 7 | 11 | window.renderMarkdown = function (src) { |
| 8 | 12 | if (!src) return '' |
| 9 | 13 |