feat(portal): content-type detection and per-family viewers for assets and resources#1028
Merged
Conversation
…s and resources Every asset and managed resource renders with the viewer its content type calls for, and content types are settled from the bytes at write time rather than trusted from whatever the writer declared. pkg/contenttype resolves media types for every write path: save_artifact, manage_artifact update, api_export, resource upload, and the REST asset create and content-update endpoints the portal UI writes through. Binary families come from http.DetectContentType; JSON, NDJSON, XML, YAML, CSV and TSV are layered on top, since a byte sniffer calls all six text/plain. Detection reads a bounded prefix, so api_export still streams to storage unbuffered. A specific declaration always wins; aliases collapse to one canonical type per family and object keys follow the detected type. When detection replaces a declaration the original is recorded in provenance as declared_content_type. Detection may only ever land on a passive family. It cannot produce text/html, text/jsx, image/svg+xml or JavaScript, which render as executing markup and render only when an author declared them deliberately. pkg/blobserve is the single writer for every raw-content endpoint: nosniff, a parsed parameter-free Content-Type, attachment for active types, and byte-range support so audio and video seek without downloading the whole object. The UI gains a renderer registry shared by the portal viewer, the public and guest viewers, collection items and the resources detail view, with a virtualized JSON tree (search, jump-to-match, JSONPath copy), NDJSON, TSV, image zoom and pan, audio and video players, an embedded PDF viewer, CodeMirror for structured text and code, and a metadata card in place of raw bytes for anything else. Inline size limits are per family. The same detection rules run client-side, so assets stored under generic types before this reach the right viewer without a data migration. The PDF viewer uses <object> with no iframe sandbox: Chrome refuses to instantiate its PDF plugin inside any sandboxed frame, so a sandboxed frame renders a broken-plugin icon instead of the document. Containment comes from the serving guarantees and the CSP's object-src 'self'. pkg/portal was at its package-size budget, so the public viewer's templates, CSP and brand mark moved to pkg/portal/publicviewer. Closes #1007
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1028 +/- ##
==========================================
+ Coverage 89.33% 89.38% +0.04%
==========================================
Files 469 475 +6
Lines 51830 52040 +210
==========================================
+ Hits 46303 46516 +213
+ Misses 3672 3666 -6
- Partials 1855 1858 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1007
Every asset and managed resource now renders with the viewer its content type calls for, and content types are settled from the bytes at write time rather than trusted from whatever the writer declared.
Content-type detection
pkg/contenttypeis the one place media types are resolved. Binary families come fromhttp.DetectContentType; JSON, NDJSON, XML, YAML, CSV and TSV are layered on top with their own heuristics, because a byte sniffer calls all sixtext/plain. Detection reads a bounded prefix (512 B for the binary sniff, 8 KB for structured text), soapi_exportstill streams to storage unbuffered:DetectStreamreplays the prefix ahead of the untouched remainder.A specific declaration always wins. Detection only runs when the declaration is absent,
application/octet-stream, ortext/plain. Every write path that accepts outside content goes through it:save_artifact,manage_artifact action=update,api_export, resource upload, and the RESTPOST /portal/assetsandPUT /portal/assets/{id}/contentendpoints the portal UI itself writes through.trino_exportsets exact types from its own formatter and is not sniffed.Aliases collapse to one canonical type per family, and object keys follow the detected type, so a bucket listing shows
.jsonrather than.bin. When detection replaces a declaration, the original is recorded in the asset's provenance asdeclared_content_type.Detection may only ever land on a passive family. It can never produce
text/html,text/jsx,image/svg+xmlor JavaScript: those render as executing markup and render only when an author declared them deliberately. Content that sniffs as HTML under atext/plaindeclaration staystext/plain; with no declaration at all it becomestext/plain. The XML heuristic rejects HTML and SVG root elements for the same reason, so nothing sneaks in throughapplication/xmleither.Normalizeenforces the RFC 2045type/subtypegrammar. A stored type ends up in aContent-Typeheader, so a value that is not a media type never reaches a caller.Viewers
ui/src/components/renderers/registry.tsreplaces the substring-match chain inContentRenderer. One table drives all four surfaces (the portal viewer, the public and guest viewers, collection items, and the resources detail view) and carries what the component cannot decide for itself: whether the source editor is offered, whether bytes are embedded or streamed from a URL, and the inline size ceiling.<pre>The source editor gains JSON, YAML, SQL and Python modes and a JSON parse-error gutter, sharing its language table with the read-only viewer.
The inline size limit is now per family. Media and PDF stream from the content endpoint and have none. The JSON and tabular viewers virtualize, so they hold a 32 MB limit, and a multi-megabyte JSON document opens in the tree rather than being refused. Text families keep the 2 MB cutoff with a download fallback.
Assets stored before any of this still carry generic types. The same rules run client-side in
ui/src/lib/contentType.ts, under the same passive-only restriction, so those assets reach the right viewer with no data migration.Serving
pkg/blobserveis the single writer for every raw-content endpoint (portal assets, asset versions, thumbnails, public share content, managed resources). All of them now carryX-Content-Type-Options: nosniff, a parsed parameter-freeContent-Type,Content-Disposition: attachmentfor active types (which never render inline on the platform's own origin) andinlinefor the passive families, and byte-range support so audio and video seek without downloading the whole object.Binary content is served from those endpoints rather than embedded in the page: the public viewer carries text content as a JSON string, which cannot hold arbitrary bytes, so images, audio, video and PDFs receive a content URL. The public viewer's CSP gains
media-srcandobject-src.Deviation from the issue
The issue specified a sandboxed iframe for PDFs. Chrome refuses to instantiate its PDF plugin inside any sandboxed frame, verified with a three-way probe (
allow-same-origin,allow-same-origin allow-scripts, and no sandbox; only the last renders the document). The PDF viewer therefore uses<object>with no sandbox, and containment comes from the serving guarantees (parsedapplication/pdfplus nosniff) and the CSP'sobject-src 'self'.Structural
pkg/portalsat at the package-size budget, so the public viewer's embedded assets (its two templates, their CSP, and the default brand mark) moved topkg/portal/publicviewer. The three public thumbnail handlers collapsed onto one shared helper. New import edges:pkg/{admin,portal,resource} -> pkg/blobserve,pkg/{portal,resource,toolkits/apigateway} -> pkg/contenttype,pkg/blobserve -> pkg/contenttype,pkg/portal -> pkg/portal/publicviewer,pkg/resource -> internal/logsan. Each is a leaf utility or a same-package subpackage; none introduces a cycle or an upward dependency.dev/platform.yamlgains aresources:block. Managed resources defaulted to aresourcesbucket the dev stack never creates, so every upload failed at storage and the whole surface was untestable locally.Acceptance criteria
Each criterion from the issue, and where it is covered.
api_exportasset whose upstream returned JSON astext/plain(or with no header) is stored asapplication/jsonand opens in the JSON viewerTestExportDetectsContentTypedrives the real export handler across the whole mislabel matrix and asserts the stored asset, version and S3 object; verified liveJsonRenderer.test.tsxcovers search counting, wrapping, reveal-inside-collapsed-subtree and clipboard; verified live on a 232 KB export, with the copied JSONPath resolving back to the matched valueexceedsInlineLimittests; the tree is virtualized via@tanstack/react-virtualcodeMirrorEditExtensionswiresjsonParseLinter;TestUpdateContentPreservesSpecificType; verified live (gutter marker appears on a broken document)TestServeRange,TestAssetContentRangeRequest,ContentRenderer.test.tsx; verified live (206 responses, audio seeked to 2.0s of a 3s clip)ContentRenderer.test.tsx; verified live in both. See the deviation note belowContentRenderer.test.tsx; verified liveResourcePreviewwired into the resourcesDetailModal. Detection on the upload path confirmed live from the server log (application/octet-streamtotext/csvand toapplication/json); the preview pane itself was exercised against seeded rows, because the dev SeaweedFS was rejecting resource writes at the timetext/plainrenders as plain text, covered by an explicit testTestDetectNeverUpgradesToActiveType,TestSaveArtifactNeverUpgradesToActiveType,TestExportNeverUpgradesToActiveType, and a render-layer testX-Content-Type-Options: nosniff, covered by handler testsTestServeSetsHardeningHeaders,TestAssetContentSetsNosniff, resource disposition testspkg/contenttype(95.2% statement coverage), plus integration tests driving the realsave_artifact,manage_artifact updateandapi_exporthandlersHow to review
The load-bearing pieces are
pkg/contenttype/contenttype.go(the detection contract and the active-type rule),pkg/blobserve/blobserve.go(one writer for every raw-content response), andui/src/components/renderers/registry.ts(the single family table). Everything else is call-site wiring or a renderer component.Worth a close look: the active-type rule is enforced in four places (Go detection, the Go XML root-element heuristic, the client-side mirror, and the serving disposition), because a gap in any one of them would let author-controlled markup render on the platform's own origin.
Verification
make verifygreen. Detection covered by table-driven tests across every family plus the mislabel matrix (JSON-as-text/plain, JSON-as-octet-stream, HTML-as-plain, empty type) and an explicit active-type-upgrade test at the detection, save, export and render layers. Integration tests drive the realsave_artifact,manage_artifact updateandapi_exporthandlers and assert on the stored asset; handler tests cover nosniff, type sanitization, disposition and 206 range responses.Exercised live against the dev stack across every family: a 232 KB JSON export declared
text/plainstored asapplication/jsonand opening in the tree, search finding and revealing a match inside a collapsed subtree with a JSONPath that resolves back to the matched value, image zoom, audio seek, PDF render, the metadata card for an unrecognized type, an HTML payload declaredtext/plainrendering as text, the resources preview pane, and the public share viewer in both themes with no console or CSP errors.