feat: add image opening functionality to DownloadActionBar#725
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an explicit Open Image action to the file share download bar and updates the open behavior so image shares can open the concrete image file path (not just the manifest root BZZ URL).
Changes:
- Update
Share.onOpen()to detect image shares and open a resolved image path under/bzz/:hash/:path. - Extend
DownloadActionBarwith a newisImageprop and render an Open Image button when applicable.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/pages/files/Share.tsx |
Adds image-aware open logic to resolve an image file path and passes isImage into the action bar. |
src/pages/files/DownloadActionBar.tsx |
Adds isImage prop and conditionally displays the new Open Image action. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/pages/files/Share.tsx
Outdated
| const imagePath = Object.keys(swarmEntries).find(path => /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(path)) | ||
| const candidatePath = imagePath || metadata?.name |
There was a problem hiding this comment.
For image manifests with multiple image entries, Object.keys(swarmEntries).find(...) will pick the first matching key based on object iteration order, which may not be deterministic and may open an unintended image. Since you already compute indexDocument from the manifest metadata, prefer using indexDocument when it’s an image (or fall back to the single entry when there’s only one file) to make the Open Image target stable and predictable.
| const imagePath = Object.keys(swarmEntries).find(path => /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(path)) | |
| const candidatePath = imagePath || metadata?.name | |
| const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg)$/i | |
| let candidatePath: string | undefined | |
| // Prefer indexDocument when it is an image | |
| if (indexDocument && imageExtensions.test(indexDocument)) { | |
| candidatePath = indexDocument | |
| } else { | |
| const entryKeys = Object.keys(swarmEntries) | |
| // If there is exactly one entry and it is an image, use it | |
| if (entryKeys.length === 1 && imageExtensions.test(entryKeys[0])) { | |
| candidatePath = entryKeys[0] | |
| } else { | |
| // Fall back to metadata.name (as before) if available | |
| candidatePath = metadata?.name | |
| } | |
| } |
src/pages/files/Share.tsx
Outdated
| const normalizedPath = candidatePath?.replace(/^\/+/, '') | ||
|
|
||
| if (normalizedPath) { | ||
| window.open(`${apiUrl}/bzz/${hash}/${encodeURI(normalizedPath)}`, '_blank') |
There was a problem hiding this comment.
encodeURI(normalizedPath) won’t encode reserved characters like ? and #, so image filenames containing those characters will produce a broken URL (everything after ? becomes a query string, # becomes a fragment). Consider encoding per path segment (e.g., split on / and encodeURIComponent each segment) so the generated /bzz/:hash/:path URL reliably targets the intended file.
| window.open(`${apiUrl}/bzz/${hash}/${encodeURI(normalizedPath)}`, '_blank') | |
| const encodedPath = normalizedPath.split('/').map(segment => encodeURIComponent(segment)).join('/') | |
| window.open(`${apiUrl}/bzz/${hash}/${encodedPath}`, '_blank') |
src/pages/files/Share.tsx
Outdated
|
|
||
| if (normalizedPath) { | ||
| window.open(`${apiUrl}/bzz/${hash}/${encodeURI(normalizedPath)}`, '_blank') |
There was a problem hiding this comment.
normalizedPath is used directly in the URL path, but it isn’t validated for dot-segments (e.g. ../). Browsers normalize these, which can change the final request path and potentially drop the intended :hash segment (opening a different BZZ reference than expected). Consider rejecting/stripping . and .. segments before constructing the URL, or ensuring the path is built in a way that prevents dot-segment normalization.
| if (normalizedPath) { | |
| window.open(`${apiUrl}/bzz/${hash}/${encodeURI(normalizedPath)}`, '_blank') | |
| const safePath = normalizedPath | |
| ? normalizedPath | |
| .split('/') | |
| .filter(segment => segment !== '.' && segment !== '..') | |
| .join('/') | |
| : '' | |
| if (safePath) { | |
| window.open(`${apiUrl}/bzz/${hash}/${encodeURI(safePath)}`, '_blank') |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/utils/file.ts
Outdated
| return path | ||
| .replaceAll('\\', '/') | ||
| .replace(/^\/+/, '') | ||
| .replace(/(?:\.\.\/)+|[?#].*/g, '') |
There was a problem hiding this comment.
sanitizeDisplayPath can return a path starting with / because it strips leading slashes before removing traversal segments. Example: sanitizeDisplayPath('../..//secret/image.png') becomes /secret/image.png, which then produces a double-slash in the constructed bzz URL. Consider re-stripping leading slashes after traversal removal, or (more robustly) normalizing via path segment splitting and removing ./.. segments instead of regex replacement.
| return path | |
| .replaceAll('\\', '/') | |
| .replace(/^\/+/, '') | |
| .replace(/(?:\.\.\/)+|[?#].*/g, '') | |
| // Strip query string and fragment first | |
| const withoutQuery = path.split(/[?#]/, 1)[0] | |
| // Normalize separators and remove leading slashes | |
| const normalized = withoutQuery | |
| .replaceAll('\\', '/') | |
| .replace(/^\/+/, '') | |
| const segments = normalized.split('/') | |
| const stack: string[] = [] | |
| for (const segment of segments) { | |
| if (!segment || segment === '.') { | |
| continue | |
| } | |
| if (segment === '..') { | |
| if (stack.length > 0) { | |
| stack.pop() | |
| } | |
| continue | |
| } | |
| stack.push(segment) | |
| } | |
| // Joining without a leading slash ensures the result never starts with '/' | |
| return stack.join('/') |
tests/unit/file.spec.ts
Outdated
| expect(sanitizeDisplayPath('/folder/../image.png')).toBe('folder/image.png') | ||
| expect(sanitizeDisplayPath('..\\secret\\image.png')).toBe('secret/image.png') | ||
| expect(sanitizeDisplayPath('image.png?download=1#preview')).toBe('image.png') | ||
| expect(sanitizeDisplayPath('/nested/path/image.png')).toBe('nested/path/image.png') |
There was a problem hiding this comment.
The new sanitizeDisplayPath tests cover basic traversal removal, but they don’t include the case where traversal stripping reintroduces a leading slash (e.g. '../..//secret/image.png'). Adding a regression test for that scenario would prevent the current bug in sanitizeDisplayPath from slipping through.
| expect(sanitizeDisplayPath('/nested/path/image.png')).toBe('nested/path/image.png') | |
| expect(sanitizeDisplayPath('/nested/path/image.png')).toBe('nested/path/image.png') | |
| expect(sanitizeDisplayPath('../..//secret/image.png')).toBe('secret/image.png') |
There was a problem hiding this comment.
I updated this to always open /bzz/${hash} only.
Bee treats the hash as a manifest, so root path resolves the index document for single-file uploads. This avoids guessing filenames (and potential 404s) and keeps noopener,noreferrer for safety.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/utils/file.ts
Outdated
| return path | ||
| .replaceAll('\\', '/') | ||
| .replace(/^\/+/, '') | ||
| .replace(/(?:\.\.\/)+|[?#].*/g, '') | ||
| .replace(/^\/+/, '') |
There was a problem hiding this comment.
sanitizeDisplayPath doesn’t fully neutralize path traversal: inputs like ".." or "../.." end up returning ".." (because the regex only strips "../"), which can then be used in URLs and be normalized by the browser to navigate outside the intended /bzz// prefix. Consider implementing dot-segment normalization (split on "/", drop "." segments, clamp ".." segments so they can’t escape root) and stripping query/fragment before normalization.
| return path | |
| .replaceAll('\\', '/') | |
| .replace(/^\/+/, '') | |
| .replace(/(?:\.\.\/)+|[?#].*/g, '') | |
| .replace(/^\/+/, '') | |
| // Normalize slashes | |
| let normalized = path.replaceAll('\\', '/') | |
| // Strip query and fragment components | |
| const queryIndex = normalized.search(/[?#]/) | |
| if (queryIndex !== -1) { | |
| normalized = normalized.slice(0, queryIndex) | |
| } | |
| // Remove leading slashes to keep the path relative | |
| normalized = normalized.replace(/^\/+/, '') | |
| // Dot-segment normalization | |
| const segments = normalized.split('/') | |
| const result: string[] = [] | |
| for (const segment of segments) { | |
| if (!segment || segment === '.') { | |
| continue | |
| } | |
| if (segment === '..') { | |
| if (result.length > 0) { | |
| result.pop() | |
| } | |
| // If there is nothing to pop, clamp at root by ignoring extra ".." | |
| continue | |
| } | |
| result.push(segment) | |
| } | |
| return result.join('/') |
tests/unit/file.spec.ts
Outdated
| expect(sanitizeDisplayPath('..\\secret\\image.png')).toBe('secret/image.png') | ||
| expect(sanitizeDisplayPath('../..//secret/image.png')).toBe('secret/image.png') | ||
| expect(sanitizeDisplayPath('image.png?download=1#preview')).toBe('image.png') | ||
| expect(sanitizeDisplayPath('/nested/path/image.png')).toBe('nested/path/image.png') |
There was a problem hiding this comment.
The sanitizeDisplayPath unit test suite doesn’t cover edge cases where the input is a bare dot-segment (e.g. "..", ".", "../.."), which currently can survive sanitization and be normalized by the browser when used in a URL. Adding assertions for these cases would help prevent regressions once the sanitization logic is hardened.
| expect(sanitizeDisplayPath('/nested/path/image.png')).toBe('nested/path/image.png') | |
| expect(sanitizeDisplayPath('/nested/path/image.png')).toBe('nested/path/image.png') | |
| expect(sanitizeDisplayPath('.')).toBe('') | |
| expect(sanitizeDisplayPath('..')).toBe('') | |
| expect(sanitizeDisplayPath('../..')).toBe('') |
This PR adds an Open Image action in the download bar and updates the open logic to resolve and open the actual image file path (instead of only the root BZZ URL). The existing download action remains unchanged, so users can now explicitly choose between opening the image and downloading it.