diff --git a/package-lock.json b/package-lock.json index fe073728..92500b77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5337,9 +5337,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.471", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.471.tgz", - "integrity": "sha512-Lo7jyyneHRYUQYM3WZ2rBEei9FcUIwValFPLifTbr4NTpc6oyUB+nN2rrOtbn3CE5pgz930mYxRJeD+glR7d6Q==", + "version": "1.5.472", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz", + "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==", "dev": true, "license": "ISC" }, diff --git a/src/humanReadablePane.css b/src/humanReadablePane.css index a5ebed8d..c4c82adc 100644 --- a/src/humanReadablePane.css +++ b/src/humanReadablePane.css @@ -1,17 +1,23 @@ .human-readable-pane { display: flex; flex-direction: column; + flex: 1 1 auto; + height: 100%; padding: 0 40px; margin: 0; + min-height: 0; min-width: 0; width: 100%; max-width: 100%; box-sizing: border-box; - overflow: hidden; + overflow: auto; } .human-readable-pane__container { - display: block; + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; min-width: 0; width: 100%; max-width: 100%; @@ -25,7 +31,7 @@ display: block; border: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); padding: 1rem; - height: var(--human-readable-pane-height, 30rem); + height: var(--human-readable-pane-height, 480px); min-width: 0; max-width: 100%; width: 100%; @@ -34,13 +40,22 @@ overflow: auto; } +.human-readable-pane__frame--markdown, +.human-readable-pane__frame--plain-text { + flex: 1 1 auto; + height: auto; + min-height: 100%; + max-height: 100%; + resize: none; +} + .human-readable-pane__frame--iframe { display: block; padding: 0; - overflow: auto; + overflow: hidden; width: 100%; max-width: 100%; - min-height: 18rem; + min-height: 0; box-sizing: border-box; background: var(--color-background, white); } @@ -53,7 +68,7 @@ solid-ui-file-explorer-provider > .paneDiv.human-readable-pane--iframe { flex: 1 1 0%; height: 100%; min-height: 0; - overflow: hidden; + overflow: visible; } .human-readable-pane__container--iframe { @@ -64,12 +79,18 @@ solid-ui-file-explorer-provider > .paneDiv.human-readable-pane--iframe { } .human-readable-pane__container--iframe > .human-readable-pane__frame--iframe { - flex: 1 1 0%; - height: auto; - min-height: 0; + flex: 1 1 auto; + height: 480px; + min-height: 288px; resize: none; } +.human-readable-pane__frame--markdown, +.human-readable-pane__frame--plain-text { + overflow: visible; + max-height: none; +} + .human-readable-pane__frame--markdown { overflow-wrap: anywhere; word-break: break-word; @@ -128,6 +149,12 @@ solid-ui-file-explorer-provider > .paneDiv.human-readable-pane--iframe { min-width: 0; } +.human-readable-pane[data-layout='mobile'] .human-readable-pane__frame--markdown, +.human-readable-pane[data-layout='mobile'] .human-readable-pane__frame--plain-text { + min-height: 100%; + max-height: none; +} + .human-readable-pane[data-layout='mobile'] { padding: 0; } @@ -141,8 +168,9 @@ solid-ui-file-explorer-provider > .paneDiv.human-readable-pane--iframe { .human-readable-pane[data-layout='mobile'] .human-readable-pane__frame--iframe { width: 100%; max-width: 100%; - min-height: 0; - height: max(16rem, min(var(--human-readable-pane-height, 30rem), 75vh)); + flex: 0 0 auto; + min-height: 100vh; + height: 100vh; } @media (max-width: 576px) { @@ -165,7 +193,8 @@ solid-ui-file-explorer-provider > .paneDiv.human-readable-pane--iframe { .human-readable-pane__frame--iframe { width: 100%; max-width: 100%; - min-height: 0; - height: max(16rem, min(var(--human-readable-pane-height, 30rem), 75vh)); + flex: 0 0 auto; + min-height: 100vh; + height: 100vh; } } diff --git a/src/humanReadablePane.ts b/src/humanReadablePane.ts index e2a793a3..988f60c4 100644 --- a/src/humanReadablePane.ts +++ b/src/humanReadablePane.ts @@ -5,12 +5,21 @@ */ import { icons, ns } from 'solid-ui' import { Util } from 'rdflib' +import { authn } from 'solid-logic' import { marked } from 'marked' import DOMPurify from 'dompurify' import type { DataBrowserContext, RenderEnvironment } from 'pane-registry' import type { NamedNode } from 'rdflib' import './humanReadablePane.css' +function viewerWebId (): string | null { + try { + return authn.currentUser()?.value ?? null + } catch { + return null + } +} + // Helper function to check if a URI has a markdown file extension type DokieliCacheValue = 'dokieli' | 'html' @@ -29,6 +38,12 @@ const isMarkdownFile = (uri?: string | null): boolean => { return /\.(md|markdown|mdown|mkd|mkdn)$/i.test(path) } +const isJsonFile = (uri?: string | null): boolean => { + if (!uri) return false + const path = uri.split('?')[0].split('#')[0] + return /\.json$/i.test(path) +} + // Cache for dokieli detection results (keyed by subject URI) const dokieliCache = new Map() @@ -41,7 +56,7 @@ function applyFrameClasses ( frame.classList.add('human-readable-pane__frame') frame.classList.add(`human-readable-pane__frame--${modifier}`) if (lines !== undefined) { - frame.style.setProperty('--human-readable-pane-height', `${lines}em`) + frame.style.setProperty('--human-readable-pane-height', `${lines * 16}px`) } } @@ -84,6 +99,43 @@ export function withBaseHref (html: string, baseUri: string): string { return base + html } +function withPreviewResizeScript (html: string): string { + const script = `` + const withScript = html.replace(/<\/body\s*>/i, `${script}$&`) + return withScript === html ? `${html}${script}` : withScript +} + function resourceFilename (uri: string): string { const encodedFilename = uri.split('/').pop() || uri try { @@ -126,7 +178,8 @@ type PreviewResponse = { const PREVIEW_REQUEST_TYPE = 'solidos-preview-request' const PREVIEW_RESPONSE_TYPE = 'solidos-preview-response' -const PREVIEW_METHODS = new Set(['GET', 'PUT', 'PATCH', 'DELETE']) +const PREVIEW_READ_METHODS = new Set(['GET', 'HEAD']) +const PREVIEW_WRITE_METHODS = new Set(['PUT', 'PATCH', 'DELETE']) function isSrcdocUrl (url: string): boolean { return url === 'about:srcdoc' || /^file:\/\/srcdoc\/?(?:[?#].*)?$/i.test(url) @@ -137,11 +190,36 @@ export function previewRequestUrl (url: string, documentUri: string): string { return isSrcdocUrl(resolvedUrl) ? documentUri : resolvedUrl } -function createPreviewBridge (html: string, uri: string, nonce: string): string { +export function isAllowedPreviewRequest (url: string, method: string, documentUri: string): boolean { + let resolvedUrl: URL + let expectedUrl: URL + try { + resolvedUrl = new URL(previewRequestUrl(url, documentUri)) + expectedUrl = new URL(documentUri) + } catch { + return false + } + + if (resolvedUrl.origin !== expectedUrl.origin) { + return false + } + + const normalizedMethod = method.toUpperCase() + const isPreviewDocument = resolvedUrl.href === expectedUrl.href + const isApprovedProfileResource = resolvedUrl.pathname === '/profile/card' + return ( + PREVIEW_READ_METHODS.has(normalizedMethod) && + (isPreviewDocument || isApprovedProfileResource) + ) || (isPreviewDocument && PREVIEW_WRITE_METHODS.has(normalizedMethod)) +} + +function createPreviewBridge (html: string, uri: string, nonce: string, webId: string | null = null): string { const bridge = `` return withBaseHref(html, uri).replace(/]*)?>/i, match => `${match}${bridge}`) @@ -421,7 +577,8 @@ function mountDokieliBridge ( frame: HTMLIFrameElement, uri: string, html: string, - authenticatedFetch: (url: string, init?: RequestInit) => Promise + authenticatedFetch: (url: string, init?: RequestInit) => Promise, + webId: string | null = null ): void { const ownerWindow = frame.ownerDocument.defaultView if (!ownerWindow) return @@ -437,18 +594,18 @@ function mountDokieliBridge ( !request || request.type !== PREVIEW_REQUEST_TYPE || request.nonce !== nonce || - previewRequestUrl(request.url, expectedUri) !== expectedUri || - !PREVIEW_METHODS.has(request.method) + !isAllowedPreviewRequest(request.url, request.method, expectedUri) ) return + const requestUrl = previewRequestUrl(request.url, expectedUri) const respond = (response: PreviewResponse) => { frame.contentWindow?.postMessage(response, '*') } - authenticatedFetch(expectedUri, { + authenticatedFetch(requestUrl, { method: request.method, headers: request.headers, - body: request.method === 'GET' ? undefined : request.body + body: PREVIEW_READ_METHODS.has(request.method) ? undefined : request.body }).then(async response => { respond({ type: PREVIEW_RESPONSE_TYPE, @@ -475,7 +632,26 @@ function mountDokieliBridge ( ownerWindow.addEventListener('message', listener) frame.setAttribute('sandbox', HTML_PREVIEW_SANDBOX) - frame.srcdoc = createPreviewBridge(html, expectedUri, nonce) + frame.srcdoc = createPreviewBridge(html, expectedUri, nonce, webId) +} + +function installPreviewAutoSize ( + frame: HTMLIFrameElement, + ownerWindow: Window +): () => void { + const onMessage = (event: MessageEvent<{ type?: string, height?: number }>) => { + const message = event.data + if ( + event.source !== frame.contentWindow || + message?.type !== 'solidos-preview-size' || + typeof message.height !== 'number' || + !Number.isFinite(message.height) + ) return + frame.style.height = `${Math.max(1, Math.ceil(message.height))}px` + frame.style.flexShrink = '0' + } + ownerWindow.addEventListener('message', onMessage) + return () => ownerWindow.removeEventListener('message', onMessage) } const humanReadablePane: HumanReadablePaneDefinition = { @@ -549,6 +725,7 @@ const humanReadablePane: HumanReadablePaneDefinition = { 'text/plain', 'text/html', 'text/markdown', + 'application/json', 'application/xhtml+xml', 'application/pdf', 'video/mp4' @@ -643,7 +820,7 @@ const humanReadablePane: HumanReadablePaneDefinition = { // Fallback: detect markdown by file extension if content-type is not text/markdown const isMarkdown = ct === 'text/markdown' || isMarkdownFile(subject.uri) - const isPlainText = ct === 'text/plain' + const isPlainText = ct === 'text/plain' || ct === 'application/json' || (!ct && isJsonFile(subject.uri)) if (ct) { // console.log('humanReadablePane: c-t:' + ct) @@ -707,6 +884,10 @@ const humanReadablePane: HumanReadablePaneDefinition = { frame.name = HUMAN_READABLE_PREVIEW_FRAME_NAME frame.title = resourceFilename(subject.uri) applyFrameClasses(frame, 'iframe') + const ownerWindow = myDocument.defaultView + const removePreviewAutoSize = ownerWindow + ? installPreviewAutoSize(frame, ownerWindow) + : () => {} const isHtml = ct === 'text/html' || ct === 'application/xhtml+xml' let previewUrl: string | null = null let disposed = false @@ -727,6 +908,7 @@ const humanReadablePane: HumanReadablePaneDefinition = { URL.revokeObjectURL(previewUrl) previewUrl = null } + removePreviewAutoSize() mutationObserver?.disconnect() } }) @@ -745,10 +927,10 @@ const humanReadablePane: HumanReadablePaneDefinition = { const isDokieli = isDokieliHtml(html) dokieliCache.set(subject.uri, isDokieli ? 'dokieli' : 'html') if (isDokieli) { - mountDokieliBridge(frame, subject.uri, html, (url, init) => kb.fetcher._fetch(url, init)) + mountDokieliBridge(frame, subject.uri, html, (url, init) => kb.fetcher._fetch(url, init), viewerWebId()) } else { frame.setAttribute('sandbox', HTML_PREVIEW_SANDBOX) - frame.srcdoc = withBaseHref(html, subject.uri) + frame.srcdoc = withPreviewResizeScript(withBaseHref(html, subject.uri)) } }).catch(error => { console.error('Error fetching HTML content:', error) @@ -764,14 +946,16 @@ const humanReadablePane: HumanReadablePaneDefinition = { const isDokieli = isDokieliHtml(html) dokieliCache.set(subject.uri, isDokieli ? 'dokieli' : 'html') if (isDokieli) { - mountDokieliBridge(frame, subject.uri, html, (url, init) => kb.fetcher._fetch(url, init)) + mountDokieliBridge(frame, subject.uri, html, (url, init) => kb.fetcher._fetch(url, init), viewerWebId()) } else { frame.setAttribute('sandbox', HTML_PREVIEW_SANDBOX) - frame.srcdoc = withBaseHref(html, subject.uri) + frame.srcdoc = withPreviewResizeScript(withBaseHref(html, subject.uri)) } return } - frame.setAttribute('sandbox', HTML_PREVIEW_SANDBOX) + if (responseContentType !== 'application/pdf') { + frame.setAttribute('sandbox', HTML_PREVIEW_SANDBOX) + } const objectUrl = URL.createObjectURL(await response.blob()) if (disposed) { URL.revokeObjectURL(objectUrl) diff --git a/test/unit/humanReadablePane.test.ts b/test/unit/humanReadablePane.test.ts index 6de22e24..b79df1b1 100644 --- a/test/unit/humanReadablePane.test.ts +++ b/test/unit/humanReadablePane.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { sym } from 'rdflib' +import { authn } from 'solid-logic' import humanReadablePane, { HUMAN_READABLE_PREVIEW_FRAME_NAME, + isAllowedPreviewRequest, previewRequestUrl, withBaseHref } from '../../src/humanReadablePane' @@ -59,7 +62,35 @@ describe('previewRequestUrl', () => { }) }) +describe('isAllowedPreviewRequest', () => { + it('allows authenticated same-origin reads for related profile resources', () => { + expect(isAllowedPreviewRequest('https://pod.example/profile/card', 'GET', uri)).toBe(true) + expect(isAllowedPreviewRequest('https://pod.example/profile/card', 'HEAD', uri)).toBe(true) + }) + + it('allows writes only to the previewed document', () => { + expect(isAllowedPreviewRequest(uri, 'PUT', uri)).toBe(true) + expect(isAllowedPreviewRequest('https://pod.example/profile/card', 'PUT', uri)).toBe(false) + }) + + it('blocks cross-origin reads', () => { + expect(isAllowedPreviewRequest('https://other.example/profile/card', 'GET', uri)).toBe(false) + expect(isAllowedPreviewRequest('https://pod.example/private/data', 'GET', uri)).toBe(false) + expect(isAllowedPreviewRequest('https://pod.example/profile/settings', 'GET', uri)).toBe(false) + }) +}) + describe('human readable pane iframe rendering', () => { + it('renders JSON as readable text', async () => { + const webOperation = vi.fn().mockResolvedValue({ responseText: '{\n "enabled": true\n}' }) + const pane = renderWith('application/json', { webOperation }) + await flush() + + expect(pane.querySelector('pre')?.textContent).toBe('{\n "enabled": true\n}') + expect(pane.querySelector('iframe')).toBeNull() + expect(webOperation).toHaveBeenCalledWith('GET', uri) + }) + it('renders HTML from the authenticated fetch instead of letting the iframe request it', async () => { const webOperation = vi.fn().mockResolvedValue({ responseText: 'Hello' }) const pane = renderWith('text/html', { webOperation }) @@ -76,6 +107,15 @@ describe('human readable pane iframe rendering', () => { expect(pane.querySelector('a')).toBeNull() }) + it('appends the resize script to HTML fragments without a closing body tag', async () => { + const pane = renderWith('text/html', { + webOperation: vi.fn().mockResolvedValue({ responseText: '
Fragment
' }) + }) + await flush() + + expect(pane.querySelector('iframe')?.srcdoc).toContain('solidos-preview-size') + }) + it('labels an HTML iframe from its fetched title and falls back to its URI filename', async () => { const titled = renderWith('text/html', { webOperation: vi.fn().mockResolvedValue({ responseText: ' A useful title ' }) @@ -90,7 +130,7 @@ describe('human readable pane iframe rendering', () => { expect(untitled.querySelector('iframe')?.title).toBe('page.html') }) - it('keeps Dokieli documents isolated and mediates exact-document requests', async () => { + it('keeps Dokieli documents isolated and mediates same-origin reads plus exact-document writes', async () => { const dokieliUri = `${window.location.origin}/docs/dok.html` const html = '

Dok

' const authenticatedFetch = vi.fn().mockResolvedValue(new Response('ok')) @@ -116,6 +156,7 @@ describe('human readable pane iframe rendering', () => { expect(frame.srcdoc).toContain('const responseBody = [204, 205, 304].includes(message.status) ? null : message.body') expect(frame.srcdoc).toContain('getAllKeys') expect(frame.srcdoc).toContain('transaction.oncomplete') + expect(frame.srcdoc).toContain('openCursor: () => openCursor(true)') window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, @@ -135,6 +176,23 @@ describe('human readable pane iframe rendering', () => { body: 'x' }) authenticatedFetch.mockClear() + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'solidos-preview-request', + nonce: frame.srcdoc.match(/const nonce = "([^"]+)"/)?.[1], + id: 'profile-read', + url: `${window.location.origin}/profile/card`, + method: 'GET' + } + })) + await flush() + expect(authenticatedFetch).toHaveBeenCalledWith(`${window.location.origin}/profile/card`, { + method: 'GET', + headers: undefined, + body: undefined + }) + authenticatedFetch.mockClear() window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data: { @@ -179,7 +237,7 @@ describe('human readable pane iframe rendering', () => { type: 'solidos-preview-request', nonce: frame.srcdoc.match(/const nonce = "([^"]+)"/)?.[1], id: 'other', - url: `${window.location.origin}/private/other-user.ttl`, + url: 'https://other.example/private/other-user.ttl', method: 'GET' } })) @@ -215,21 +273,34 @@ describe('human readable pane iframe rendering', () => { expect(frame.srcdoc).toContain('compromised') }) - it('sandboxes blob previews even when the actual response is non-HTML', async () => { + it('does not sandbox PDF blob previews so the browser viewer can render them', async () => { const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:https://pod.example/resource') - const response = new Response('image data', { - headers: { 'content-type': 'image/png' } + const response = new Response('pdf data', { + headers: { 'content-type': 'application/pdf' } }) const pane = renderWith('application/octet-stream', { _fetch: vi.fn().mockResolvedValue(response) }) await flush() const frame = pane.querySelector('iframe')! - expect(frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals') + expect(frame.getAttribute('sandbox')).toBeNull() expect(frame.src).toBe('blob:https://pod.example/resource') expect(createObjectURL).toHaveBeenCalled() createObjectURL.mockRestore() }) + it('keeps non-PDF blob previews sandboxed', async () => { + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:https://pod.example/resource') + const response = new Response('', { + headers: { 'content-type': 'image/svg+xml' } + }) + const pane = renderWith('image/svg+xml', { _fetch: vi.fn().mockResolvedValue(response) }) + await flush() + + expect(pane.querySelector('iframe')?.getAttribute('sandbox')) + .toBe('allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals') + createObjectURL.mockRestore() + }) + it('revokes a blob URL when the preview is removed before fetching completes', async () => { let resolveResponse!: (response: Response) => void const responsePromise = new Promise(resolve => { resolveResponse = resolve }) @@ -276,4 +347,35 @@ describe('human readable pane iframe rendering', () => { expect(pane.querySelector('iframe')?.title).toBe('file%FF.pdf') }) + + it('gives Dokieli the signed-in viewer as a bridged session', async () => { + const webId = 'https://pod.example/profile/card#me' + const spy = vi.spyOn(authn, 'currentUser').mockReturnValue(sym(webId) as any) + const dokieliUri = `${window.location.origin}/docs/dok-session.html` + const html = '' + const subject = { uri: dokieliUri, doc: () => subject } + const store = { + fetcher: { + getHeader: vi.fn().mockReturnValue(['text/html']), + webOperation: vi.fn().mockResolvedValue({ responseText: html }), + _fetch: vi.fn() + } + } + try { + const pane = humanReadablePane.render(subject as any, { dom: document, session: { store } } as any) + await flush() + const srcdoc = pane.querySelector('iframe')!.srcdoc + expect(srcdoc).toContain(`const viewerWebId = ${JSON.stringify(webId)}`) + expect(srcdoc).toContain('if (bridgedSession) config.Session = bridgedSession') + expect(srcdoc).toContain('Object.defineProperty(config, \'DocumentURL\'') + expect(srcdoc.indexOf('bridgedSession')).toBeLessThan(srcdoc.indexOf('dokieli.js')) + + spy.mockReturnValue(null) + const loggedOut = humanReadablePane.render(subject as any, { dom: document, session: { store } } as any) + await flush() + expect(loggedOut.querySelector('iframe')!.srcdoc).toContain('const viewerWebId = null') + } finally { + spy.mockRestore() + } + }) })