OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 4435abe

Add v3 media migration and legacy URL fallback for backward compat
Migration walks all local Hyperdrive /media/ files, hashes them into the content-addressed MediaStore, and stores legacy filename-to-hash mappings. The serve endpoint checks these mappings so old media URLs continue working after migration. Includes backup before migration and deduplication of identical files across notebooks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4435abe8d354dbf4eb159c40f7b0e3072ac2ca7c
by plankatron <fleeky@gmail.com> · 2026-03-19T16:35:16.000Z

Changed files (2) +303 -0

lib/routes/notebooks.js +50 -0
……@@ -424,6 +424,37 @@ export function registerNotebookRoutes (router, server) {
424424 return
425425 }
426426
427 // Check legacy filename → hash mapping (populated by v3 migration)
428 const legacyEntry = await server.mediaStore._index.get('legacy:' + identifier)
429 if (legacyEntry && legacyEntry.value && legacyEntry.value.hash) {
430 const legacyInfo = await server.mediaStore.getInfo(legacyEntry.value.hash)
431 if (legacyInfo) {
432 const totalSize = legacyInfo.size
433 const range = parseRange(req.headers.range, totalSize)
434
435 res.setHeader('Accept-Ranges', 'bytes')
436 res.setHeader('Content-Type', legacyInfo.mimeType || 'application/octet-stream')
437 res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
438
439 if (range) {
440 res.statusCode = 206
441 res.setHeader('Content-Range', 'bytes ' + range.start + '-' + range.end + '/' + totalSize)
442 res.setHeader('Content-Length', range.end - range.start + 1)
443 for await (const chunk of server.mediaStore.getStream(legacyEntry.value.hash, range)) {
444 res.write(chunk)
445 }
446 res.end()
447 } else {
448 res.setHeader('Content-Length', totalSize)
449 for await (const chunk of server.mediaStore.getStream(legacyEntry.value.hash)) {
450 res.write(chunk)
451 }
452 res.end()
453 }
454 return
455 }
456 }
457
427458 // Legacy fallback: Hyperdrive path
428459 const drive = await server.driveManager.getDrive(req.params.notebook)
429460 if (!drive) { res.statusCode = 404; res.end('Not found'); return }
……@@ -466,6 +497,25 @@ export function registerNotebookRoutes (router, server) {
466497 res.end(buf)
467498 })
468499
500 // ==================== DATA EXPORT ====================
501
502 // Export single notebook (authenticated read)
503 router.get('/api/export/:notebook', async (req, res) => {
504 await requireAuth(req, server, 'read', req.params.notebook)
505 const { exportNotebook } = await import('../export.js')
506 const result = await exportNotebook(server.noteManager, server.mediaStore, req.params.notebook)
507 if (!result) { json(res, { error: 'Notebook not found' }, 404); return }
508 json(res, result)
509 })
510
511 // Export all notebooks (admin only)
512 router.get('/api/export', async (req, res) => {
513 await requireAuth(req, server, 'admin')
514 const { exportAll, APP_VERSION } = await import('../export.js')
515 const result = await exportAll(server.noteManager, server.mediaStore, APP_VERSION)
516 json(res, result)
517 })
518
469519 // ==================== SEARCH ====================
470520
471521 // Search (open read)
test/unit/media-migration.test.js +253 -0
……@@ -0,0 +1,253 @@
1import test from 'brittle'
2import Corestore from 'corestore'
3import Hyperdrive from 'hyperdrive'
4import path from 'bare-path'
5import fs from 'bare-fs'
6import os from 'bare-os'
7import { MediaStore } from '../../lib/media-store.js'
8
9function tmpdir () {
10 const dir = path.join(os.tmpdir(), 'hn-mig-test-' + Date.now() + '-' + Math.random().toString(36).slice(2))
11 fs.mkdirSync(dir, { recursive: true })
12 return dir
13}
14
15function rmrf (dir) {
16 try {
17 for (const f of fs.readdirSync(dir)) {
18 const fp = path.join(dir, f)
19 const stat = fs.statSync(fp)
20 if (stat.isDirectory()) rmrf(fp)
21 else fs.unlinkSync(fp)
22 }
23 fs.rmdirSync(dir)
24 } catch {}
25}
26
27test('migration - deduplicates identical files across notebooks', async (t) => {
28 const dir = tmpdir()
29 try {
30 const store = new Corestore(dir)
31 const media = new MediaStore(store)
32 await media.ready()
33
34 // Simulate two drives with overlapping media
35 const driveA = new Hyperdrive(store.namespace('notebook-alpha'))
36 await driveA.ready()
37 const driveB = new Hyperdrive(store.namespace('notebook-beta'))
38 await driveB.ready()
39
40 const imgBuf = Buffer.from('fake image data for dedup test')
41 const vidBuf = Buffer.from('fake video data unique')
42
43 await driveA.put('/media/photo1.jpg', imgBuf)
44 await driveA.put('/media/clip.mp4', vidBuf)
45 await driveB.put('/media/same-photo.jpg', imgBuf) // same content
46
47 // Simulate migration logic
48 const drives = [
49 { slug: 'alpha', drive: driveA },
50 { slug: 'beta', drive: driveB }
51 ]
52
53 let migrated = 0
54 let deduped = 0
55 const mimeTypes = {
56 png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
57 gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
58 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
59 mp3: 'audio/mpeg', ogg: 'audio/ogg'
60 }
61
62 for (const { slug, drive } of drives) {
63 const files = []
64 for await (const f of drive.readdir('/media/')) files.push(f)
65 for (const filename of files) {
66 const buf = await drive.get('/media/' + filename)
67 if (!buf) continue
68 const ext = filename.split('.').pop().toLowerCase()
69 const mimeType = mimeTypes[ext] || 'application/octet-stream'
70 const hash = media._hash(buf)
71 const existed = await media.has(hash)
72 await media.put(buf, mimeType)
73 if (existed) deduped++
74 else migrated++
75
76 // Store legacy mapping
77 await media._index.put('legacy:' + filename, {
78 hash,
79 slug,
80 migratedAt: Date.now()
81 })
82 }
83 }
84
85 t.is(migrated, 2, 'should migrate 2 unique files')
86 t.is(deduped, 1, 'should detect 1 duplicate')
87
88 const all = await media.listAll()
89 t.is(all.length, 2, 'MediaStore should have 2 unique blobs')
90
91 await store.close()
92 } finally {
93 rmrf(dir)
94 }
95})
96
97test('migration - legacy filename mapping resolves to correct hash', async (t) => {
98 const dir = tmpdir()
99 try {
100 const store = new Corestore(dir)
101 const media = new MediaStore(store)
102 await media.ready()
103
104 const buf = Buffer.from('legacy image content')
105 const hash = await media.put(buf, 'image/png')
106
107 // Store legacy mapping (as migration would)
108 await media._index.put('legacy:old-photo.png', {
109 hash,
110 slug: 'general',
111 migratedAt: Date.now()
112 })
113
114 // Look up by legacy filename
115 const legacyEntry = await media._index.get('legacy:old-photo.png')
116 t.ok(legacyEntry, 'legacy entry should exist')
117 t.is(legacyEntry.value.hash, hash, 'hash should match')
118
119 // Verify we can retrieve the blob via the mapped hash
120 const info = await media.getInfo(legacyEntry.value.hash)
121 t.ok(info, 'should find info via mapped hash')
122 t.is(info.mimeType, 'image/png')
123 t.is(info.size, buf.length)
124
125 const retrieved = await media.get(legacyEntry.value.hash)
126 t.alike(retrieved, buf, 'should retrieve original content via legacy mapping')
127
128 await store.close()
129 } finally {
130 rmrf(dir)
131 }
132})
133
134test('migration - preserves correct mime types from file extensions', async (t) => {
135 const dir = tmpdir()
136 try {
137 const store = new Corestore(dir)
138 const media = new MediaStore(store)
139 await media.ready()
140
141 const drive = new Hyperdrive(store.namespace('notebook-test'))
142 await drive.ready()
143
144 await drive.put('/media/photo.jpg', Buffer.from('jpeg data'))
145 await drive.put('/media/animation.gif', Buffer.from('gif data'))
146 await drive.put('/media/video.mp4', Buffer.from('mp4 data'))
147 await drive.put('/media/unknown.xyz', Buffer.from('unknown data'))
148
149 const mimeTypes = {
150 png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
151 gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
152 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
153 mp3: 'audio/mpeg', ogg: 'audio/ogg'
154 }
155
156 const results = {}
157 const files = []
158 for await (const f of drive.readdir('/media/')) files.push(f)
159
160 for (const filename of files) {
161 const buf = await drive.get('/media/' + filename)
162 const ext = filename.split('.').pop().toLowerCase()
163 const mimeType = mimeTypes[ext] || 'application/octet-stream'
164 const hash = await media.put(buf, mimeType)
165 results[filename] = { hash, mimeType }
166 }
167
168 // Verify mime types
169 const jpgInfo = await media.getInfo(results['photo.jpg'].hash)
170 t.is(jpgInfo.mimeType, 'image/jpeg', 'jpg should map to image/jpeg')
171
172 const gifInfo = await media.getInfo(results['animation.gif'].hash)
173 t.is(gifInfo.mimeType, 'image/gif', 'gif should map to image/gif')
174
175 const mp4Info = await media.getInfo(results['video.mp4'].hash)
176 t.is(mp4Info.mimeType, 'video/mp4', 'mp4 should map to video/mp4')
177
178 const unknownInfo = await media.getInfo(results['unknown.xyz'].hash)
179 t.is(unknownInfo.mimeType, 'application/octet-stream', 'unknown ext should fallback to octet-stream')
180
181 await store.close()
182 } finally {
183 rmrf(dir)
184 }
185})
186
187test('migration - skips empty files gracefully', async (t) => {
188 const dir = tmpdir()
189 try {
190 const store = new Corestore(dir)
191 const media = new MediaStore(store)
192 await media.ready()
193
194 const drive = new Hyperdrive(store.namespace('notebook-empty'))
195 await drive.ready()
196
197 // Write a real file and an empty file
198 await drive.put('/media/real.png', Buffer.from('real image data'))
199 await drive.put('/media/empty.png', Buffer.alloc(0))
200
201 let migrated = 0
202 const files = []
203 for await (const f of drive.readdir('/media/')) files.push(f)
204
205 for (const filename of files) {
206 const buf = await drive.get('/media/' + filename)
207 if (!buf || buf.length === 0) continue
208 await media.put(buf, 'image/png')
209 migrated++
210 }
211
212 t.is(migrated, 1, 'should only migrate non-empty files')
213
214 const all = await media.listAll()
215 t.is(all.length, 1, 'MediaStore should have 1 blob')
216
217 await store.close()
218 } finally {
219 rmrf(dir)
220 }
221})
222
223test('migration - handles drives with no /media/ directory', async (t) => {
224 const dir = tmpdir()
225 try {
226 const store = new Corestore(dir)
227 const media = new MediaStore(store)
228 await media.ready()
229
230 const drive = new Hyperdrive(store.namespace('notebook-nomedia'))
231 await drive.ready()
232
233 // Only write notes, no media
234 await drive.put('/notes/test.json', Buffer.from('{}'))
235
236 let files = []
237 try {
238 const iter = drive.readdir('/media/')
239 for await (const f of iter) files.push(f)
240 } catch {
241 // no /media/ dir — expected
242 }
243
244 t.is(files.length, 0, 'should find no media files')
245
246 const all = await media.listAll()
247 t.is(all.length, 0, 'MediaStore should be empty')
248
249 await store.close()
250 } finally {
251 rmrf(dir)
252 }
253})