| … | … | @@ -6,6 +6,7 @@ |
| 6 | 6 | |
| 7 | 7 | import { json, readJson } from '../response.js' |
| 8 | 8 | import { requireAuth } from './auth-helpers.js' |
| 9 | import { parseRange } from '../media-store.js' |
| 9 | 10 | |
| 10 | 11 | /** |
| 11 | 12 | * Parse query string from raw request URL |
| … | … | @@ -371,58 +372,73 @@ export function registerNotebookRoutes (router, server) { |
| 371 | 372 | 'audio/ogg': '.ogg' |
| 372 | 373 | }[contentType] || '.bin' |
| 373 | 374 | |
| 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) |
| 378 | 377 | |
| 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) |
| 384 | 380 | |
| 385 | | // Try Hyperdrive first (local/writable drive), fall back to SharedNotebook blobs |
| 381 | // Also record in SharedNotebook for multi-writer sync if available |
| 386 | 382 | 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') |
| 395 | 385 | } |
| 396 | 386 | |
| 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 }) |
| 399 | 389 | // Sync media to site drive in background |
| 400 | 390 | if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {}) |
| 401 | 391 | }) |
| 402 | 392 | |
| 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 | } |
| 411 | 424 | return |
| 412 | 425 | } |
| 413 | 426 | |
| 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 |
| 415 | 432 | let buf = await drive.get(mediaPath) |
| 416 | 433 | |
| 417 | | // Fallback: check SharedNotebook blob store |
| 434 | // Fallback: SharedNotebook blob store |
| 418 | 435 | if (!buf) { |
| 419 | 436 | const snb = server.noteManager._getSharedNotebook(req.params.notebook) |
| 420 | 437 | if (snb) { |
| 421 | 438 | try { |
| 422 | 439 | 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) |
| 426 | 442 | if (entry && entry.value && entry.value.blobId) { |
| 427 | 443 | buf = await snb.getBlob(entry.value.blobId) |
| 428 | 444 | } |
| … | … | @@ -438,7 +454,7 @@ export function registerNotebookRoutes (router, server) { |
| 438 | 454 | return |
| 439 | 455 | } |
| 440 | 456 | |
| 441 | | const ext = req.params.filename.split('.').pop() |
| 457 | const ext = identifier.split('.').pop() |
| 442 | 458 | const types = { |
| 443 | 459 | png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', |
| 444 | 460 | gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', |