Harden export preview handling
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 07:04:07 +02:00
parent 21b84dae6c
commit ff21911ac5
10 changed files with 111 additions and 15 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog # Changelog
## Sprint 38 Export Center preview hardening (2026-06-17)
- Prevented HTML project report artifacts from being offered through the JSON preview path in the frontend Export Center.
- Added a clear backend `EXPORT_CONTENT_UNSUPPORTED` response when `/api/v1/exports/{export_id}/content` is called for HTML report artifacts.
- Extracted export JSON preview rendering into `frontend/src/components/exports/ExportPreview.tsx`.
- Added regression coverage for HTML report content-preview rejection.
- No API routes, migrations, product features, provider fetching or AI behavior were introduced.
## Sprint 37 Tower PostgreSQL collation maintenance (2026-06-17) ## Sprint 37 Tower PostgreSQL collation maintenance (2026-06-17)
- Created a live Tower database backup before collation maintenance: `backups/geointel-before-collation-refresh-20260617-065707.dump`. - Created a live Tower database backup before collation maintenance: `backups/geointel-before-collation-refresh-20260617-065707.dump`.
+7
View File
@@ -192,6 +192,13 @@ class ExportService:
if not export: if not export:
raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404) raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404)
path = ExportService.get_export_download_path(db, export_id) path = ExportService.get_export_download_path(db, export_id)
if export.export_type == "project_report_html" or path.suffix.lower() in {".html", ".htm"}:
raise AppError(
code="EXPORT_CONTENT_UNSUPPORTED",
message="Export content preview is only available for JSON and GeoJSON artifacts. Download HTML report artifacts instead.",
details={"export_type": export.export_type},
status_code=415,
)
try: try:
content = json.loads(path.read_text(encoding="utf-8")) content = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
@@ -259,6 +259,28 @@ def test_export_content_reads_persisted_artifact(tmp_path) -> None:
assert response.content == {"hello": "world"} assert response.content == {"hello": "world"}
def test_export_content_rejects_html_report_preview(tmp_path) -> None:
export_id = uuid4()
export_path = tmp_path / "report.html"
export_path.write_text("<!doctype html><html><body>report</body></html>", encoding="utf-8")
export = Export(
id=export_id,
project_id=uuid4(),
export_type="project_report_html",
storage_path=str(export_path),
metadata_json={"format": "html"},
)
db = FakeSession({(Export, export_id): export})
try:
ExportService.get_export_content(db, export_id)
except AppError as exc:
assert exc.code == "EXPORT_CONTENT_UNSUPPORTED"
assert exc.status_code == 415
else:
raise AssertionError("HTML report artifacts must be download-only through the content preview API")
def test_export_download_path_rejects_missing_artifact(tmp_path) -> None: def test_export_download_path_rejects_missing_artifact(tmp_path) -> None:
export_id = uuid4() export_id = uuid4()
export = Export( export = Export(
+13 -5
View File
@@ -1010,14 +1010,22 @@ Returns one persisted export record.
### GET `/api/v1/exports/{export_id}/content` ### GET `/api/v1/exports/{export_id}/content`
Returns the stored JSON artifact content through the standard API envelope. Returns the stored JSON artifact content through the standard API envelope.
HTML report artifacts are intentionally download-only through `/download`.
Calling `/content` for `project_report_html` returns:
```text
code: EXPORT_CONTENT_UNSUPPORTED
message: Export content preview is only available for JSON and GeoJSON artifacts. Download HTML report artifacts instead.
```
### GET `/api/v1/exports/{export_id}/download` ### GET `/api/v1/exports/{export_id}/download`
Downloads the stored JSON/GeoJSON export artifact as a raw file response with Downloads the stored JSON/GeoJSON/HTML export artifact as a raw file response
`application/json` content type and a `Content-Disposition` attachment with a `Content-Disposition` attachment filename. JSON and GeoJSON artifacts
filename. This endpoint intentionally does not use the JSON envelope because use `application/json`; HTML report artifacts use `text/html`. This endpoint
it is a browser/file-download path; callers that need canonical API JSON should intentionally does not use the JSON envelope because it is a browser/file-download
use `/content`. path; callers that need canonical API JSON should use `/content` for JSON/GeoJSON
artifacts.
### POST `/api/v1/exports/yolo` ### POST `/api/v1/exports/yolo`
+22
View File
@@ -1,3 +1,25 @@
## Sprint 38 Export Center preview hardening (2026-06-17)
Changed:
- Hardened the export content preview path so HTML report artifacts return `EXPORT_CONTENT_UNSUPPORTED` instead of a generic JSON parse failure.
- Updated the frontend Export Center to offer JSON preview only for JSON/GeoJSON artifacts.
- HTML project report artifacts now display as download-only in the export list.
- Extracted export preview rendering from `frontend/src/App.tsx` into `frontend/src/components/exports/ExportPreview.tsx`.
- Updated API/frontend docs, changelog and TODO status.
Validation:
- `cd backend && python -m pytest tests/test_sprint17_export_foundation.py -q` passed: 10 tests.
- `cd frontend && npm run typecheck` passed.
Open:
- Full release readiness and Tower redeploy/browser smoke are still recommended before treating this pass as deployed.
Limitations:
- This pass does not add new export formats, PDF reports, provider fetching, AI inference or migrations.
Next recommended pass:
- Run the full readiness gate, frontend build and Tower deploy smoke; then continue with shared workbench orchestration decomposition or export cleanup/history filtering.
## Sprint 37 Tower PostgreSQL collation maintenance (2026-06-17) ## Sprint 37 Tower PostgreSQL collation maintenance (2026-06-17)
Changed: Changed:
+2 -1
View File
@@ -50,7 +50,8 @@ This file now starts with the current implementation status. Older preparation/b
- [x] QA/QC results and map workspace component decomposition. - [x] QA/QC results and map workspace component decomposition.
- [x] Docker Compose port/storage/database configuration via `.env` defaults for Unraid. - [x] Docker Compose port/storage/database configuration via `.env` defaults for Unraid.
- [x] Single-container `geointel` Unraid compose/template runtime. - [x] Single-container `geointel` Unraid compose/template runtime.
- [ ] Further frontend component decomposition for export preview and shared workbench orchestration. - [x] Export preview component decomposition and HTML report download-only UX hardening.
- [ ] Further frontend shared workbench orchestration decomposition.
## Sprint 8 status ## Sprint 8 status
+2
View File
@@ -208,6 +208,8 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks. - Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
- The MapLibre chunk is intentionally larger than generic app chunks because it contains the GIS map runtime; the Vite warning threshold is set to keep this known vendor dependency visible without warning on every release build. - The MapLibre chunk is intentionally larger than generic app chunks because it contains the GIS map runtime; the Vite warning threshold is set to keep this known vendor dependency visible without warning on every release build.
- Export preview rendering lives in `src/components/exports/ExportPreview.tsx`.
- The Export Center only offers JSON preview for JSON/GeoJSON artifacts; HTML project reports are shown as download-only artifacts.
## Raster dependency visibility ## Raster dependency visibility
+2 -6
View File
@@ -9,6 +9,7 @@ import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
import { DatasetPanel } from './components/datasets/DatasetPanel' import { DatasetPanel } from './components/datasets/DatasetPanel'
import { DetectionLab } from './components/detection/DetectionLab' import { DetectionLab } from './components/detection/DetectionLab'
import { ExportCenter } from './components/exports/ExportCenter' import { ExportCenter } from './components/exports/ExportCenter'
import { ExportPreview } from './components/exports/ExportPreview'
import { MapWorkspace } from './components/map/MapWorkspace' import { MapWorkspace } from './components/map/MapWorkspace'
import { AreaPanel } from './components/project/AreaPanel' import { AreaPanel } from './components/project/AreaPanel'
import { ProjectPanel } from './components/project/ProjectPanel' import { ProjectPanel } from './components/project/ProjectPanel'
@@ -740,12 +741,7 @@ function App(): JSX.Element {
onPreviewContent={previewExportContent} onPreviewContent={previewExportContent}
onDownload={downloadExportArtifact} onDownload={downloadExportArtifact}
/> />
{exportPreview ? ( <ExportPreview content={exportPreview} />
<section>
<h2>Export Preview</h2>
<pre className="job-result">{JSON.stringify(exportPreview, null, 2)}</pre>
</section>
) : null}
<DatasetPanel <DatasetPanel
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
@@ -24,6 +24,16 @@ function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson' return datasetType === 'vector' || datasetType === 'geojson'
} }
function canPreviewJson(item: ExportRead): boolean {
if (item.export_type === 'project_report_html') {
return false
}
if (item.metadata_json?.format === 'html') {
return false
}
return true
}
export function ExportCenter({ export function ExportCenter({
selectedProjectId, selectedProjectId,
selectedDataset, selectedDataset,
@@ -80,9 +90,13 @@ export function ExportCenter({
<div>status: {item.status}</div> <div>status: {item.status}</div>
<div>path: {item.storage_path}</div> <div>path: {item.storage_path}</div>
<div>export id: {item.id}</div> <div>export id: {item.id}</div>
<button type="button" onClick={() => onPreviewContent(item.id)}> {canPreviewJson(item) ? (
Preview JSON content <button type="button" onClick={() => onPreviewContent(item.id)}>
</button> Preview JSON content
</button>
) : (
<div>Preview: download-only HTML artifact</div>
)}
<button type="button" onClick={() => onDownload(item.id)}> <button type="button" onClick={() => onDownload(item.id)}>
Download artifact Download artifact
</button> </button>
@@ -0,0 +1,16 @@
interface ExportPreviewProps {
content: Record<string, unknown> | null
}
export function ExportPreview({ content }: ExportPreviewProps): JSX.Element | null {
if (!content) {
return null
}
return (
<section>
<h2>Export Preview</h2>
<pre className="job-result">{JSON.stringify(content, null, 2)}</pre>
</section>
)
}