Skip to content

fix(content): URL-decode content-collection file path before lookup (spaces/umlauts) (#326)#336

Merged
rbuergi merged 1 commit into
mainfrom
fix/content-collection-path-decode-326-wt
Jul 6, 2026
Merged

fix(content): URL-decode content-collection file path before lookup (spaces/umlauts) (#326)#336
rbuergi merged 1 commit into
mainfrom
fix/content-collection-path-decode-326-wt

Conversation

@rbuergi

@rbuergi rbuergi commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #326

Problem

Content-collection files whose path contains a space (or a non-ASCII umlaut) 404 from a deployed portal. The /static/{**path} file-serving endpoint looked up the collection with the raw catch-all route value, which ASP.NET Core leaves percent-encoded (catch-all parameters keep their encoding so the multi-segment path's / separators survive). So a file stored at

Reports/Data Extraction/Übersicht 2025.pdf

reached the collection lookup as

Reports/Data%20Extraction/%C3%9Cbersicht%202025.pdf   →  not found in collection 'content'

Every file under such a folder appeared to have vanished from the portal UI, while a kernel read with the decoded path (IContentService/ContentCollection.GetContentAsync) succeeded — bytes intact. ASCII/space-free paths were unaffected.

Root cause

ContentLayoutArea.RenderPdf builds the /static/... URL with Uri.EscapeDataString on each path segment, but ResolveStatic in BlazorHostingExtensions never applied the inverse before handing the path to the collection lookup — so %20/%C3%9C reached GetContentAsync verbatim and missed the real stored path.

Fix

Add DecodeContentPath — per-segment Uri.UnescapeDataString (restores %20 and UTF-8 escapes like %C3%9CÜ, without turning an escaped slash into a false separator) — and apply it where the file path is built in both endpoint patterns (/static/{collection}/{filePath} and /static/{address}/{collection}/{filePath}). Applied only to the file-path portion (not the already-handled collection segment), at the boundary where the still-encoded catch-all value is consumed, so it decodes exactly once — a plain ASCII path carries no escapes and is a no-op.

Test

ContentPathDecodingTest (MeshWeaver.Hosting.Blazor.Test, HubTestBase, no mocking):

  • Integration: a real file-system content collection holding Reports/Data Extraction/Übersicht 2025.pdf; the raw encoded path misses (reproduces the bug), the decoded path resolves the file with bytes intact.
  • Theory: pins per-segment decoding for space+umlaut paths, the ASCII no-op, and a %20-in-name case that would break under a double-decode (decode-exactly-once).

Release -warnaserror -p:CIRun=true build of the touched project + test project is clean; all 6 tests pass locally.

🤖 Generated with Claude Code

…326)

The /static/{**path} file-serving endpoint looked up the collection with the
raw catch-all route value. ASP.NET Core leaves catch-all parameters
percent-encoded (so the multi-segment path's '/' separators survive), so a
file under a folder with a space or umlaut arrived as
'Reports/Data%20Extraction/%C3%9Cbersicht%202025.pdf' and missed the stored
'Reports/Data Extraction/Übersicht 2025.pdf' — every file under such a folder
appeared to have vanished from the portal, while a kernel read with the
decoded path succeeded.

RenderPdf builds the URL with Uri.EscapeDataString per segment; the serving
side never applied the inverse. Add DecodeContentPath (per-segment
Uri.UnescapeDataString — handles %20 AND UTF-8 %C3%9C, without turning an
escaped slash into a false separator) and apply it where the file path is
built in both endpoint patterns. A plain ASCII path carries no escapes, so
decoding is a no-op — never a double-decode.

Test: real file-system content collection with a space+umlaut path; the
encoded path misses, the decoded path resolves the file with bytes intact,
plus a theory pinning decode-exactly-once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Results (shard 2)

697 tests  ±0   695 ✅ ±0   1m 50s ⏱️ +2s
  9 suites ±0     2 💤 ±0 
  9 files   ±0     0 ❌ ±0 

Results for commit 7c8a260. ± Comparison against base commit 339c21f.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Results (shard 1)

1 029 tests  ±0   1 028 ✅ ±0   3m 27s ⏱️ -26s
    8 suites ±0       1 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit 7c8a260. ± Comparison against base commit 339c21f.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Results (shard 5)

774 tests  +154   592 ✅ +154   3m 6s ⏱️ + 1m 21s
  9 suites ±  0   182 💤 ±  0 
  9 files   ±  0     0 ❌ ±  0 

Results for commit 7c8a260. ± Comparison against base commit 339c21f.

This pull request removes 15 and adds 169 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.CopyModifyCopyBackTest ‑ CopyModifyCopyBack_UpdatesOnlyDeltas
MeshWeaver.Hosting.Monolith.Test.CreateOrUpdateNodeRequestTest ‑ Upsert_OnExistingTarget_PreservesIdentityFields
MeshWeaver.Hosting.Monolith.Test.CreateOrUpdateNodeRequestTest ‑ Upsert_OnExistingTarget_UpdatesViaStream_WasCreated_False
MeshWeaver.Hosting.Monolith.Test.CreateOrUpdateNodeRequestTest ‑ Upsert_OnMissingTarget_CreatesAndReports_WasCreated_True
MeshWeaver.Hosting.Monolith.Test.DynamicGraphFileSystemPersistenceTest ‑ FileSystem_CodeConfiguration_LoadedFromChildMeshNodes
MeshWeaver.Hosting.Monolith.Test.DynamicGraphFileSystemPersistenceTest ‑ FileSystem_PersistenceService_FindsNodeTypeNode_WithPolymorphicDeserialization
MeshWeaver.Hosting.Monolith.Test.ObserveQueryFreshnessTest ‑ ObserveQuery_AccessAssignment_AddedEventArrives_AfterCreateNode
MeshWeaver.Hosting.Monolith.Test.ObserveQueryFreshnessTest ‑ ObserveQuery_AccessAssignment_InitialIncludesPriorCreate
MeshWeaver.Hosting.Monolith.Test.ObserveQueryFreshnessTest ‑ ObserveQuery_AfterCreate_ReturnsTheJustCreatedNode
MeshWeaver.Hosting.Monolith.Test.ObserveQueryFreshnessTest ‑ ObserveQuery_AfterUpdate_ReturnsTheLatestContent
…
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Berichte/%C3%BCbersicht.pdf", expected: "Berichte/übersicht.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Data%20%2520%20Extraction.pdf", expected: "Data %20 Extraction.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Data%20Extraction/%C3%9Cbersicht%202025.pdf", expected: "Data Extraction/Übersicht 2025.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Reports/2025/summary.pdf", expected: "Reports/2025/summary.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Reports/Data%20Extraction/%C3%9Cbersicht%202025.pd"···, expected: "Reports/Data Extraction/Übersicht 2025.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ ServingPath_WithSpaceAndUmlaut_ResolvesAfterDecoding
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionPartitionedTest ‑ PartitionedPath_ObserveAgents_FromHostedSubHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionPartitionedTest ‑ PartitionedPath_ObserveAgents_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionPartitionedTest ‑ PartitionedPath_ObserveModels_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.BrokenNodeTypeAccessTest ‑ AccessingInstance_OfNonCompilingNodeType_AnswersTerminalError_NotSilence
…

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Results (shard 4)

   10 files  ±0     10 suites  ±0   4m 49s ⏱️ -6s
1 359 tests ±0  1 359 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 398 runs  ±0  1 398 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 7c8a260. ± Comparison against base commit 339c21f.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Results (shard 3)

819 tests  ±0   819 ✅ ±0   3m 47s ⏱️ -9s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 7c8a260. ± Comparison against base commit 339c21f.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Results (shard 0)

648 tests  ±0   648 ✅ ±0   5m 1s ⏱️ -8s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 7c8a260. ± Comparison against base commit 339c21f.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Results

   56 files  ±  0     56 suites  ±0   22m 3s ⏱️ +34s
5 326 tests +154  5 141 ✅ +154  185 💤 ±0  0 ❌ ±0 
5 365 runs  +154  5 180 ✅ +154  185 💤 ±0  0 ❌ ±0 

Results for commit 7c8a260. ± Comparison against base commit 339c21f.

This pull request removes 15 and adds 169 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.CopyModifyCopyBackTest ‑ CopyModifyCopyBack_UpdatesOnlyDeltas
MeshWeaver.Hosting.Monolith.Test.CreateOrUpdateNodeRequestTest ‑ Upsert_OnExistingTarget_PreservesIdentityFields
MeshWeaver.Hosting.Monolith.Test.CreateOrUpdateNodeRequestTest ‑ Upsert_OnExistingTarget_UpdatesViaStream_WasCreated_False
MeshWeaver.Hosting.Monolith.Test.CreateOrUpdateNodeRequestTest ‑ Upsert_OnMissingTarget_CreatesAndReports_WasCreated_True
MeshWeaver.Hosting.Monolith.Test.DynamicGraphFileSystemPersistenceTest ‑ FileSystem_CodeConfiguration_LoadedFromChildMeshNodes
MeshWeaver.Hosting.Monolith.Test.DynamicGraphFileSystemPersistenceTest ‑ FileSystem_PersistenceService_FindsNodeTypeNode_WithPolymorphicDeserialization
MeshWeaver.Hosting.Monolith.Test.ObserveQueryFreshnessTest ‑ ObserveQuery_AccessAssignment_AddedEventArrives_AfterCreateNode
MeshWeaver.Hosting.Monolith.Test.ObserveQueryFreshnessTest ‑ ObserveQuery_AccessAssignment_InitialIncludesPriorCreate
MeshWeaver.Hosting.Monolith.Test.ObserveQueryFreshnessTest ‑ ObserveQuery_AfterCreate_ReturnsTheJustCreatedNode
MeshWeaver.Hosting.Monolith.Test.ObserveQueryFreshnessTest ‑ ObserveQuery_AfterUpdate_ReturnsTheLatestContent
…
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Berichte/%C3%BCbersicht.pdf", expected: "Berichte/übersicht.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Data%20%2520%20Extraction.pdf", expected: "Data %20 Extraction.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Data%20Extraction/%C3%9Cbersicht%202025.pdf", expected: "Data Extraction/Übersicht 2025.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Reports/2025/summary.pdf", expected: "Reports/2025/summary.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ DecodeContentPath_RestoresEscapedSegments(encoded: "Reports/Data%20Extraction/%C3%9Cbersicht%202025.pd"···, expected: "Reports/Data Extraction/Übersicht 2025.pdf")
MeshWeaver.Hosting.Blazor.Test.ContentPathDecodingTest ‑ ServingPath_WithSpaceAndUmlaut_ResolvesAfterDecoding
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionPartitionedTest ‑ PartitionedPath_ObserveAgents_FromHostedSubHub_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionPartitionedTest ‑ PartitionedPath_ObserveAgents_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.AgentPickerProjectionPartitionedTest ‑ PartitionedPath_ObserveModels_PopulatesCombobox
MeshWeaver.Hosting.Monolith.Test.BrokenNodeTypeAccessTest ‑ AccessingInstance_OfNonCompilingNodeType_AnswersTerminalError_NotSilence
…

@rbuergi rbuergi merged commit dd22131 into main Jul 6, 2026
15 checks passed
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.

Content-collection file serving fails for paths with spaces — lookup uses the URL-encoded path

1 participant