-
Notifications
You must be signed in to change notification settings - Fork 1
feat(api): add HTTP Range request support for streaming #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
This leads to problems serializing TOML, see also: status-im/nim-serialization#104
Implements directory manifests for organizing multiple files into a tree:
- New DirectoryCodec (0xCD04) for directory manifest CIDs
- DirectoryManifest type with entries, totalSize, filesCount
- Two-phase upload: files uploaded individually, then /directory endpoint
finalizes them into a tree structure with POST JSON body
- HTML directory browsing with Archivist branding at /data/{cid}
- JSON directory listing when Accept header doesn't include text/html
- Path resolution within directories via /data/{cid}/path?p=subdir/file
- Auto-promotion of single-child root directories
API changes:
- POST /api/archivist/v1/directory - finalize directory from uploaded files
- GET /api/archivist/v1/data/{cid} - now serves HTML for directories
- Added MIME types: audio/mpeg, audio/flac, video/webm, etc.
- Relaxed filename validation to allow paths like "Album/track.mp3"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Add HEAD endpoint for /api/archivist/v1/data/{cid} to support
metadata preflight checks (content-type, size) without full download
- Add HEAD endpoint for /api/archivist/v1/data/{cid}/network/stream
to support jsmediatags ID3 tag reading
- Fix directory traversal check to allow ".." in filenames (e.g.
"F.R.E.S.H..mp3") while still blocking actual traversal attacks
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Implements byte-range requests (RFC 7233) for file downloads: - Add RangeStream wrapper for partial content streaming - Parse Range header (e.g., "bytes=0-499", "bytes=500-") - Return 206 Partial Content with Content-Range header - Return 416 Range Not Satisfiable for invalid ranges - Advertise Accept-Ranges: bytes on all file responses Enables seeking in media players for audio/video content. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit
nph
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Lines 554 to 556 in 8a080d1
| without subdirManifest =? ( | |
| await fetchDirectoryManifest(node.networkStore, subdirCid) | |
| ), err: |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Lines 559 to 564 in 8a080d1
| entries.add(DirectoryEntry.new( | |
| name = name, | |
| cid = subdirCid, | |
| size = subdirManifest.totalSize, | |
| isDirectory = true, | |
| )) |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Lines 568 to 574 in 8a080d1
| entries.add(DirectoryEntry.new( | |
| name = f.name, | |
| cid = f.cid, | |
| size = f.size, | |
| isDirectory = false, | |
| mimetype = if f.mimetype.isSome: f.mimetype.unsafeGet() else: "", | |
| )) |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Lines 577 to 583 in 8a080d1
| entries.sort(proc(a, b: DirectoryEntry): int = | |
| if a.isDirectory and not b.isDirectory: | |
| return -1 | |
| elif not a.isDirectory and b.isDirectory: | |
| return 1 | |
| else: | |
| return cmp(a.name, b.name) |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Lines 586 to 589 in 8a080d1
| let dirManifest = DirectoryManifest.new( | |
| entries = entries, | |
| name = dirNode.name, | |
| ) |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Line 644 in 8a080d1
| without directory =? (await fetchDirectoryManifest(node.networkStore, cidVal)), err: |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Line 656 in 8a080d1
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Lines 672 to 673 in 8a080d1
| resp.setHeader("Content-Disposition", | |
| "attachment; filename=\"" & manifest.filename.get() & "\"") |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Line 700 in 8a080d1
| without directory =? (await fetchDirectoryManifest(node.networkStore, cidVal)), err: |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Line 729 in 8a080d1
| return RestApiResponse.response($json, contentType = "application/json", headers = headers) |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Line 866 in 8a080d1
| let pathParts = if pathStr.len > 0: pathStr.split('/') else: @[] |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Lines 870 to 872 in 8a080d1
| return RestApiResponse.redirect( | |
| Http307, "/api/archivist/v1/data/" & $cidVal | |
| ) |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Lines 896 to 898 in 8a080d1
| return RestApiResponse.error( | |
| Http404, "Path not found: " & part, headers = headers | |
| ) |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Line 916 in 8a080d1
| await node.retrieveCid(foundEntry.cid, local = true, resp = resp, byteRange = byteRange) |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/api.nim
Line 925 in 8a080d1
| without subDir =? (await fetchDirectoryManifest(node.networkStore, foundEntry.cid)), err: |
[nph] reported by reviewdog 🐶
| const ArchivistLogoSvg* = """<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 400 400" preserveAspectRatio="xMidYMid meet"> |
[nph] reported by reviewdog 🐶
| const DirectoryListingCss* = """ |
[nph] reported by reviewdog 🐶
| mime == "application/gzip" or mime == "application/x-7z-compressed": |
[nph] reported by reviewdog 🐶
| dirName = if directory.name.len > 0: directory.name else: cidStr[0 ..< 12] & "..." |
[nph] reported by reviewdog 🐶
| pathParts = if basePath.len > 0: basePath.strip(chars = {'/'}).split('/') else: @[] |
[nph] reported by reviewdog 🐶
| var html = fmt"""<!DOCTYPE html> |
[nph] reported by reviewdog 🐶
| html &= fmt""" <span class="separator">/</span> |
[nph] reported by reviewdog 🐶
| html &= fmt""" </nav> |
[nph] reported by reviewdog 🐶
| html &= fmt""" <div class="file-row"> |
[nph] reported by reviewdog 🐶
| let parentPath = if pathParts.len > 1: |
[nph] reported by reviewdog 🐶
| html &= fmt""" <div class="file-row"> |
[nph] reported by reviewdog 🐶
| html &= """ <div class="empty-dir">This directory is empty</div> |
[nph] reported by reviewdog 🐶
archivist-node/archivist/rest/directoryhtml.nim
Lines 463 to 465 in 8a080d1
| displayName = if entry.isDirectory: entryName & "/" else: entryName | |
| html &= fmt""" <div class="file-row"> |
[nph] reported by reviewdog 🐶
| html &= fmt""" </div> |
[nph] reported by reviewdog 🐶
archivist-node/archivist/streams/rangestream.nim
Lines 27 to 32 in 8a080d1
| type | |
| RangeStream* = ref object of LPStream | |
| source*: SeekableStream | |
| rangeStart*: int # First byte of the range (inclusive) | |
| rangeEnd*: int # Last byte of the range (inclusive) | |
| bytesRemaining*: int # Bytes left to read in this range |
[nph] reported by reviewdog 🐶
archivist-node/archivist/streams/rangestream.nim
Lines 40 to 43 in 8a080d1
| T: type RangeStream, | |
| source: SeekableStream, | |
| rangeStart: int, | |
| rangeEnd: int |
[nph] reported by reviewdog 🐶
archivist-node/archivist/streams/rangestream.nim
Lines 50 to 53 in 8a080d1
| source: source, | |
| rangeStart: rangeStart, | |
| rangeEnd: rangeEnd, | |
| bytesRemaining: rangeLen |
| ProtoBuffer, initProtoBuffer, getField, getRequiredRepeatedField, | ||
| write, finish, ProtoResult |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| ProtoBuffer, initProtoBuffer, getField, getRequiredRepeatedField, | |
| write, finish, ProtoResult | |
| ProtoBuffer, initProtoBuffer, getField, getRequiredRepeatedField, write, finish, | |
| ProtoResult |
| entries.add(DirectoryEntry( | ||
| name: entryName, | ||
| cid: entryCid, | ||
| size: size.NBytes, | ||
| isDirectory: isDir != 0, | ||
| mimetype: mimetype, | ||
| )) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| entries.add(DirectoryEntry( | |
| name: entryName, | |
| cid: entryCid, | |
| size: size.NBytes, | |
| isDirectory: isDir != 0, | |
| mimetype: mimetype, | |
| )) | |
| entries.add( | |
| DirectoryEntry( | |
| name: entryName, | |
| cid: entryCid, | |
| size: size.NBytes, | |
| isDirectory: isDir != 0, | |
| mimetype: mimetype, | |
| ) | |
| ) |
| success DirectoryManifest( | ||
| entries: entries, | ||
| totalSize: totalSize.NBytes, | ||
| name: name, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| success DirectoryManifest( | |
| entries: entries, | |
| totalSize: totalSize.NBytes, | |
| name: name, | |
| ) | |
| success DirectoryManifest(entries: entries, totalSize: totalSize.NBytes, name: name) |
| cid = blk.cid, | ||
| entries = directory.entries.len, | ||
| totalSize = directory.totalSize |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| cid = blk.cid, | |
| entries = directory.entries.len, | |
| totalSize = directory.totalSize | |
| cid = blk.cid, entries = directory.entries.len, totalSize = directory.totalSize |
| cid*: Cid | ||
| size*: NBytes | ||
| isDirectory*: bool | ||
| mimetype*: string # Empty string = not set |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| mimetype*: string # Empty string = not set | |
| mimetype*: string # Empty string = not set |
| # Validate and normalize path | ||
| var normalPath = pathStr.replace("\\", "/") | ||
| while normalPath.len > 0 and normalPath[0] == '/': | ||
| normalPath = normalPath[1..^1] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| normalPath = normalPath[1..^1] | |
| normalPath = normalPath[1 ..^ 1] |
| for part in pathParts: | ||
| if part == "..": | ||
| return RestApiResponse.error( | ||
| Http400, "Invalid path (directory traversal not allowed): " & pathStr, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| Http400, "Invalid path (directory traversal not allowed): " & pathStr, | |
| Http400, | |
| "Invalid path (directory traversal not allowed): " & pathStr, |
| inputEntries.add(InputEntry( | ||
| path: normalPath, | ||
| cid: cidVal, | ||
| size: size, | ||
| mimetype: mimetypeOpt, | ||
| )) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| inputEntries.add(InputEntry( | |
| path: normalPath, | |
| cid: cidVal, | |
| size: size, | |
| mimetype: mimetypeOpt, | |
| )) | |
| inputEntries.add( | |
| InputEntry(path: normalPath, cid: cidVal, size: size, mimetype: mimetypeOpt) | |
| ) |
| current.subdirs[part] = DirNode( | ||
| name: part, | ||
| subdirs: initTable[string, DirNode](), | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| current.subdirs[part] = DirNode( | |
| name: part, | |
| subdirs: initTable[string, DirNode](), | |
| ) | |
| current.subdirs[part] = | |
| DirNode(name: part, subdirs: initTable[string, DirNode]()) |
| current.files.add(( | ||
| name: pathParts[^1], | ||
| cid: entry.cid, | ||
| size: entry.size, | ||
| mimetype: entry.mimetype, | ||
| )) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nph] reported by reviewdog 🐶
| current.files.add(( | |
| name: pathParts[^1], | |
| cid: entry.cid, | |
| size: entry.size, | |
| mimetype: entry.mimetype, | |
| )) | |
| current.files.add( | |
| ( | |
| name: pathParts[^1], | |
| cid: entry.cid, | |
| size: entry.size, | |
| mimetype: entry.mimetype, | |
| ) | |
| ) |
Implements byte-range requests (RFC 7233) for file downloads:
- Add RangeStream wrapper for partial content streaming
- Parse Range header (e.g., "bytes=0-499", "bytes=500-")
- Return 206 Partial Content with Content-Range header
- Return 416 Range Not Satisfiable for invalid ranges
- Advertise Accept-Ranges: bytes on all file responses