Skip to content

feat(portal): content-type detection and per-family viewers for assets and resources#1028

Merged
cjimti merged 1 commit into
mainfrom
feat/1007-content-type-viewers
Jul 23, 2026
Merged

feat(portal): content-type detection and per-family viewers for assets and resources#1028
cjimti merged 1 commit into
mainfrom
feat/1007-content-type-viewers

Conversation

@cjimti

@cjimti cjimti commented Jul 23, 2026

Copy link
Copy Markdown
Member

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/contenttype is the one place media types are resolved. Binary families come from http.DetectContentType; JSON, NDJSON, XML, YAML, CSV and TSV are layered on top with their own heuristics, because a byte sniffer calls all six text/plain. Detection reads a bounded prefix (512 B for the binary sniff, 8 KB for structured text), so api_export still streams to storage unbuffered: DetectStream replays the prefix ahead of the untouched remainder.

A specific declaration always wins. Detection only runs when the declaration is absent, application/octet-stream, or text/plain. Every write path that accepts outside content goes through it: save_artifact, manage_artifact action=update, api_export, resource upload, and the REST POST /portal/assets and PUT /portal/assets/{id}/content endpoints the portal UI itself writes through. trino_export sets 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 .json rather than .bin. When detection replaces a declaration, the original is recorded in the asset's provenance as declared_content_type.

Detection may only ever land on a passive family. It can never produce text/html, text/jsx, image/svg+xml or JavaScript: those render as executing markup and render only when an author declared them deliberately. Content that sniffs as HTML under a text/plain declaration stays text/plain; with no declaration at all it becomes text/plain. The XML heuristic rejects HTML and SVG root elements for the same reason, so nothing sneaks in through application/xml either.

Normalize enforces the RFC 2045 type/subtype grammar. A stored type ends up in a Content-Type header, so a value that is not a media type never reaches a caller.

Viewers

ui/src/components/renderers/registry.ts replaces the substring-match chain in ContentRenderer. 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.

Family Viewer
JSON Collapsible tree, virtualized. Search across keys and values with match count and jump-to-match, JSONPath breadcrumb with copy-path and copy-value, type-aware values, tree/formatted/raw views
JSON Lines One expandable row per record, each opening into the JSON viewer
CSV, TSV Sortable, searchable table (the existing renderer, extended to TSV)
Images Zoom and pan, checkerboard backing for transparency, dimensions and size readout
Audio, video Native players with working seek
PDF Embedded viewer with a download fallback
XML, YAML, SQL, Python, JavaScript, plain text CodeMirror, read-only, with folding, line numbers and a wrap toggle
Everything else A metadata card naming the type and size with a download action, instead of raw bytes in a <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/blobserve is the single writer for every raw-content endpoint (portal assets, asset versions, thumbnails, public share content, managed resources). All of them now carry X-Content-Type-Options: nosniff, a parsed parameter-free Content-Type, Content-Disposition: attachment for active types (which never render inline on the platform's own origin) and inline for 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-src and object-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 (parsed application/pdf plus nosniff) and the CSP's object-src 'self'.

Structural

pkg/portal sat at the package-size budget, so the public viewer's embedded assets (its two templates, their CSP, and the default brand mark) moved to pkg/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.yaml gains a resources: block. Managed resources defaulted to a resources bucket 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.

Criterion Covered by
An api_export asset whose upstream returned JSON as text/plain (or with no header) is stored as application/json and opens in the JSON viewer TestExportDetectsContentType drives the real export handler across the whole mislabel matrix and asserts the stored asset, version and S3 object; verified live
The motivating case works end to end in the portal and public viewers, with search, jump-to-match, copy-path and the raw/formatted toggle JsonRenderer.test.tsx covers 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 value
A multi-MB JSON asset stays responsive and is not blocked by the large-asset guard exceedsInlineLimit tests; the tree is virtualized via @tanstack/react-virtual
Editing JSON in the source editor gives highlighting and a parse-error indicator, and saving preserves the content type codeMirrorEditExtensions wires jsonParseLinter; TestUpdateContentPreservesSpecificType; verified live (gutter marker appears on a broken document)
An image displays inline with zoom/pan instead of binary garbage; audio and video play with working seek (Range verified) TestServeRange, TestAssetContentRangeRequest, ContentRenderer.test.tsx; verified live (206 responses, audio seeked to 2.0s of a 3s clip)
A PDF displays in an embedded viewer in both the portal and public viewers ContentRenderer.test.tsx; verified live in both. See the deviation note below
An unrecognized binary type shows the metadata-plus-download card, never raw bytes ContentRenderer.test.tsx; verified live
A managed resource shows an inline preview; one with a generic multipart type is stored with the detected type ResourcePreview wired into the resources DetailModal. Detection on the upload path confirmed live from the server log (application/octet-stream to text/csv and to application/json); the preview pane itself was exercised against seeded rows, because the dev SeaweedFS was rejecting resource writes at the time
A payload sniffed as HTML but declared text/plain renders as plain text, covered by an explicit test TestDetectNeverUpgradesToActiveType, TestSaveArtifactNeverUpgradesToActiveType, TestExportNeverUpgradesToActiveType, and a render-layer test
All raw content responses carry X-Content-Type-Options: nosniff, covered by handler tests TestServeSetsHardeningHeaders, TestAssetContentSetsNosniff, resource disposition tests
Table-driven detection tests across all families plus the mislabel matrix; integration tests for export-to-render pkg/contenttype (95.2% statement coverage), plus integration tests driving the real save_artifact, manage_artifact update and api_export handlers

How 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), and ui/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 verify green. 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 real save_artifact, manage_artifact update and api_export handlers 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/plain stored as application/json and 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 declared text/plain rendering as text, the resources preview pane, and the public share viewer in both themes with no console or CSP errors.

…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

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.91979% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.38%. Comparing base (b67a6ec) to head (b50ae27).

Files with missing lines Patch % Lines
pkg/contenttype/structured.go 88.28% 8 Missing and 7 partials ⚠️
pkg/resource/handler.go 90.47% 1 Missing and 1 partial ⚠️
pkg/toolkits/apigateway/export.go 87.50% 1 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cjimti
cjimti merged commit 6dc4f2b into main Jul 23, 2026
10 checks passed
@cjimti
cjimti deleted the feat/1007-content-type-viewers branch July 23, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Robust content-type detection and best-in-class viewers/editors for assets and resources

1 participant