OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 94b2ce1

Wire MediaStore into HypernoteServer and update media upload/serve endpoints
- Import MediaStore in hypernote-server.js, instantiate in constructor - Initialize mediaStore in ready(), wire isReferencedAnywhere to scan all notebooks - Add _isMediaReferenced method for GC reference checking - Upload endpoint now uses content-addressed MediaStore.put() instead of Hyperdrive - Upload response includes hash field for content-addressed URLs - Serve endpoint tries MediaStore first with range request support (206 Partial) - Serve endpoint falls back to legacy Hyperdrive and SharedNotebook blob paths - Import parseRange in notebooks.js for streaming range responses Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
94b2ce12a035ee2c74813f0313d6b77eb68dd1a9
by plankatron <fleeky@gmail.com> · 2026-03-19T16:30:04.000Z

Changed files (2) +80 -34

hypernote-server.js +30 -0
……@@ -37,6 +37,7 @@ import { NoteManager } from './lib/note-manager.js'
3737import { NotebookCrypto } from './lib/notebook-crypto.js'
3838import { LinkManager } from './lib/link-manager.js'
3939import { SitePublisher } from './lib/site-publisher.js'
40import { MediaStore, parseRange } from './lib/media-store.js'
4041import { MultiwriterManager } from './lib/multiwriter/manager.js'
4142import { Router } from './lib/routes/index.js'
4243import { registerNotebookRoutes } from './lib/routes/notebooks.js'
……@@ -131,6 +132,9 @@ class HypernoteServer {
131132 // DriveManager (per-notebook Hyperdrives)
132133 this.driveManager = new DriveManager(this.store, this.registry)
133134
135 // MediaStore (content-addressed blob storage)
136 this.mediaStore = new MediaStore(this.store)
137
134138 // ReplicationManager (Hyperswarm P2P)
135139 this.replicationManager = new ReplicationManager(this.store, this.driveManager)
136140
……@@ -311,6 +315,14 @@ class HypernoteServer {
311315 // Ready corestore + hyperbee
312316 await this.registry.ready()
313317
318 // Ready media store
319 await this.mediaStore.ready()
320
321 // Wire isReferencedAnywhere to scan all notebooks
322 this.mediaStore.isReferencedAnywhere = async (hash) => {
323 return this._isMediaReferenced(hash)
324 }
325
314326 // Write storage locator breadcrumb
315327 await StorageLocator.write(this.storage)
316328
……@@ -552,6 +564,24 @@ class HypernoteServer {
552564 } catch {}
553565 }
554566
567 /**
568 * Check if a media hash is referenced in any notebook's note content.
569 */
570 async _isMediaReferenced (hash) {
571 const { extractMediaHashes } = await import('./lib/multiwriter/notebook-apply.js')
572 const notebooks = await this.noteManager.listNotebooks()
573 for (const nb of notebooks) {
574 try {
575 const notes = await this.noteManager.listNotes(nb.slug)
576 for (const note of notes) {
577 const hashes = extractMediaHashes(note.content || '')
578 if (hashes.has(hash)) return true
579 }
580 } catch {}
581 }
582 return false
583 }
584
555585 /**
556586 * Migrate from Phase 1-5 single-drive layout to per-notebook drives.
557587 */
lib/routes/notebooks.js +50 -34
……@@ -6,6 +6,7 @@
66
77import { json, readJson } from '../response.js'
88import { requireAuth } from './auth-helpers.js'
9import { parseRange } from '../media-store.js'
910
1011/**
1112 * Parse query string from raw request URL
……@@ -371,58 +372,73 @@ export function registerNotebookRoutes (router, server) {
371372 'audio/ogg': '.ogg'
372373 }[contentType] || '.bin'
373374
374 // Generate unique filename
375 const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 8)
376 const filename = id + ext
377 const drivePath = '/media/' + filename
375 // Content-addressed storage via MediaStore
376 const hash = await server.mediaStore.put(body, contentType)
378377
379 const drive = await server.driveManager.getDrive(req.params.notebook)
380 if (!drive) {
381 json(res, { error: 'Notebook not found' }, 404)
382 return
383 }
378 // Cancel any pending GC for this hash (re-referenced)
379 server.mediaStore.cancelOrphan(hash)
384380
385 // Try Hyperdrive first (local/writable drive), fall back to SharedNotebook blobs
381 // Also record in SharedNotebook for multi-writer sync if available
386382 const snb = server.noteManager._getSharedNotebook(req.params.notebook)
387 if (drive.writable) {
388 await drive.put(drivePath, body)
389 } else if (snb) {
390 const blobId = await snb.storeBlob(body)
391 await snb.addMedia(null, { hash: filename, mimeType: contentType, filename, size: body.length, blobId }, 'user')
392 } else {
393 json(res, { error: 'Notebook is read-only on this instance' }, 403)
394 return
383 if (snb) {
384 await snb.addMedia(null, { hash, mimeType: contentType, filename: hash, size: body.length }, 'user')
395385 }
396386
397 const url = '/api/notebooks/' + encodeURIComponent(req.params.notebook) + '/media/' + filename
398 json(res, { url, filename, size: body.length, contentType })
387 const url = '/api/notebooks/' + encodeURIComponent(req.params.notebook) + '/media/' + hash
388 json(res, { url, hash, filename: hash, size: body.length, contentType })
399389 // Sync media to site drive in background
400390 if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
401391 })
402392
403 // Serve media from notebook drive
404 router.get('/api/notebooks/:notebook/media/:filename', async (req, res) => {
405 if (!validateMediaFilename(res, req.params.filename)) return
406 // No auth for media — public if you have the URL
407 const drive = await server.driveManager.getDrive(req.params.notebook)
408 if (!drive) {
409 res.statusCode = 404
410 res.end('Not found')
393 // Serve media — content-addressed (MediaStore) with legacy fallback
394 router.get('/api/notebooks/:notebook/media/:identifier', async (req, res) => {
395 if (!validateMediaFilename(res, req.params.identifier)) return
396
397 const identifier = req.params.identifier
398
399 // Try MediaStore first (content-addressed)
400 const info = await server.mediaStore.getInfo(identifier)
401 if (info) {
402 const totalSize = info.size
403 const range = parseRange(req.headers.range, totalSize)
404
405 res.setHeader('Accept-Ranges', 'bytes')
406 res.setHeader('Content-Type', info.mimeType || 'application/octet-stream')
407 res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
408
409 if (range) {
410 res.statusCode = 206
411 res.setHeader('Content-Range', 'bytes ' + range.start + '-' + range.end + '/' + totalSize)
412 res.setHeader('Content-Length', range.end - range.start + 1)
413 for await (const chunk of server.mediaStore.getStream(identifier, range)) {
414 res.write(chunk)
415 }
416 res.end()
417 } else {
418 res.setHeader('Content-Length', totalSize)
419 for await (const chunk of server.mediaStore.getStream(identifier)) {
420 res.write(chunk)
421 }
422 res.end()
423 }
411424 return
412425 }
413426
414 const mediaPath = '/media/' + req.params.filename
427 // Legacy fallback: Hyperdrive path
428 const drive = await server.driveManager.getDrive(req.params.notebook)
429 if (!drive) { res.statusCode = 404; res.end('Not found'); return }
430
431 const mediaPath = '/media/' + identifier
415432 let buf = await drive.get(mediaPath)
416433
417 // Fallback: check SharedNotebook blob store
434 // Fallback: SharedNotebook blob store
418435 if (!buf) {
419436 const snb = server.noteManager._getSharedNotebook(req.params.notebook)
420437 if (snb) {
421438 try {
422439 await snb.update()
423 // Try new index first, then legacy key format
424 let entry = await snb.view.get('media-file:' + req.params.filename)
425 if (!entry) entry = await snb.view.get('media:_:' + req.params.filename)
440 let entry = await snb.view.get('media-file:' + identifier)
441 if (!entry) entry = await snb.view.get('media:_:' + identifier)
426442 if (entry && entry.value && entry.value.blobId) {
427443 buf = await snb.getBlob(entry.value.blobId)
428444 }
……@@ -438,7 +454,7 @@ export function registerNotebookRoutes (router, server) {
438454 return
439455 }
440456
441 const ext = req.params.filename.split('.').pop()
457 const ext = identifier.split('.').pop()
442458 const types = {
443459 png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
444460 gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',