From 58b8eea3a05d8e3ab6a4404380c6b552d42fdad0 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 18 May 2026 12:41:36 +0300 Subject: [PATCH 1/5] WIP: start rich notes contract batch 5 (checkpoint) From 4b70309a3a735ad0db306c9fa468c2a1fbebc79b Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 18 May 2026 13:02:52 +0300 Subject: [PATCH 2/5] WIP: add rich notes request normalization contracts (checkpoint) --- Models/Dtos/Editor/EditorAreaRequestParser.cs | 10 +- .../Dtos/Editor/EditorPlaceRequestParser.cs | 11 +- .../Dtos/Editor/EditorRegionRequestParser.cs | 9 +- .../Dtos/Editor/EditorRichNotesRequestHtml.cs | 216 ++++++++++++++++++ .../Dtos/Editor/EditorSegmentRequestParser.cs | 9 +- .../EditorTripMetadataUpdateRequestParser.cs | 9 +- .../TripEditorMetadataControllerTests.cs | 23 ++ .../Models/EditorRichNotesRequestHtmlTests.cs | 161 +++++++++++++ .../tripEditorRichNotesPersistence.spec.ts | 114 +++++++++ 9 files changed, 526 insertions(+), 36 deletions(-) create mode 100644 Models/Dtos/Editor/EditorRichNotesRequestHtml.cs create mode 100644 tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs create mode 100644 tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts diff --git a/Models/Dtos/Editor/EditorAreaRequestParser.cs b/Models/Dtos/Editor/EditorAreaRequestParser.cs index f152f1c2..293ccbca 100644 --- a/Models/Dtos/Editor/EditorAreaRequestParser.cs +++ b/Models/Dtos/Editor/EditorAreaRequestParser.cs @@ -11,10 +11,6 @@ internal static class EditorAreaRequestParser { private static readonly string[] SaveServerOwnedFields = { "id", "tripId", "displayOrder", "capabilities" }; private static readonly Regex FillHexRegex = new("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled); - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse a complete-draft area create request. /// @@ -46,7 +42,7 @@ public static bool TryParseCreate(JsonElement request, out EditorAreaSaveRequest update = new EditorAreaSaveRequest( string.IsNullOrWhiteSpace(name) ? "Area" : name!.Trim(), - notesHtml, + EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), string.IsNullOrWhiteSpace(fillHex) ? "#ff6600" : fillHex!.Trim().ToLowerInvariant(), geometry!); return true; @@ -78,7 +74,7 @@ public static bool TryParseUpdate(JsonElement request, out EditorAreaSaveRequest return false; } - update = new EditorAreaSaveRequest(name!.Trim(), notesHtml, fillHex!.Trim().ToLowerInvariant(), geometry!); + update = new EditorAreaSaveRequest(name!.Trim(), EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), fillHex!.Trim().ToLowerInvariant(), geometry!); return true; } @@ -350,7 +346,7 @@ private static void ValidateFillHex(string? fillHex, Dictionary errors) { - if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml)) + if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml)) { errors["notesHtml"] = new[] { "Notes cannot contain data image sources." }; } diff --git a/Models/Dtos/Editor/EditorPlaceRequestParser.cs b/Models/Dtos/Editor/EditorPlaceRequestParser.cs index 9146c08b..baacc3a8 100644 --- a/Models/Dtos/Editor/EditorPlaceRequestParser.cs +++ b/Models/Dtos/Editor/EditorPlaceRequestParser.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; namespace Wayfarer.Models.Dtos.Editor; @@ -17,10 +16,6 @@ internal static class EditorPlaceRequestParser "capabilities" }; - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse a complete-draft place create request. /// @@ -51,7 +46,7 @@ public static bool TryParseCreate( update = new EditorPlaceCreateRequest( fields.Name!, - fields.NotesHtml, + EditorRichNotesRequestHtml.NormalizeForPersistence(fields.NotesHtml), fields.Address, fields.Location, fields.IconName!, @@ -90,7 +85,7 @@ public static bool TryParseUpdate( update = new EditorPlaceUpdateRequest( regionId!.Value, fields.Name!, - fields.NotesHtml, + EditorRichNotesRequestHtml.NormalizeForPersistence(fields.NotesHtml), fields.Address, fields.Location, fields.IconName!, @@ -337,7 +332,7 @@ private static void RejectPathOwnedField(JsonElement request, string field, Dict } private static bool ContainsDataImageSource(string? notesHtml) => - !string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml); + EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml); private sealed record PlaceSaveFields( string? Name, diff --git a/Models/Dtos/Editor/EditorRegionRequestParser.cs b/Models/Dtos/Editor/EditorRegionRequestParser.cs index b30d5361..f24d2ed8 100644 --- a/Models/Dtos/Editor/EditorRegionRequestParser.cs +++ b/Models/Dtos/Editor/EditorRegionRequestParser.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; namespace Wayfarer.Models.Dtos.Editor; @@ -20,10 +19,6 @@ internal static class EditorRegionRequestParser "capabilities" }; - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse a complete-draft region save request. /// @@ -57,7 +52,7 @@ public static bool TryParseSave( return false; } - update = new EditorRegionSaveRequest(name!, notesHtml, coverImage, center); + update = new EditorRegionSaveRequest(name!, EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), coverImage, center); return true; } @@ -276,7 +271,7 @@ private static void ValidateCoverImage(string? rawUrl, Dictionary errors) { - if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml)) + if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml)) { errors["notesHtml"] = new[] { "Notes images must use external image URLs, not data:image sources." }; } diff --git a/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs b/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs new file mode 100644 index 00000000..a9742986 --- /dev/null +++ b/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs @@ -0,0 +1,216 @@ +using System.Net; +using System.Text.RegularExpressions; +using AngleSharp.Dom; +using AngleSharp.Html.Parser; +using Microsoft.AspNetCore.WebUtilities; + +namespace Wayfarer.Models.Dtos.Editor; + +/// +/// Normalizes Trip Editor rich-notes request HTML before editor mutations persist it. +/// +internal static class EditorRichNotesRequestHtml +{ + private static readonly Regex DataImageSourceRegex = new( + @"]*?\bsrc\s*=\s*[""']?\s*data:image/", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex ImageSourceRegex = new( + @"(?]*?\bsrc\s*=\s*[""'])(?[^""']+)(?[""'])", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly HtmlParser Parser = new(); + private static readonly HashSet AllowedTags = new(StringComparer.OrdinalIgnoreCase) + { + "a", "blockquote", "br", "em", "h1", "h2", "h3", "h4", "h5", "h6", "img", "li", "ol", "p", "span", "strong", "u", "ul" + }; + + private static readonly HashSet RemovedTags = new(StringComparer.OrdinalIgnoreCase) + { + "base", "button", "embed", "form", "iframe", "input", "link", "meta", "object", "option", "script", "select", "style", "textarea" + }; + + private static readonly HashSet AllowedClasses = new(StringComparer.Ordinal) + { + "ql-align-center", "ql-align-right", "ql-font-monospace", "ql-font-serif" + }; + + private static readonly HashSet AllowedListKinds = new(StringComparer.Ordinal) + { + "bullet", "ordered" + }; + + /// + /// Returns true when request HTML contains a direct embedded data image source. + /// + public static bool ContainsDataImageSource(string? notesHtml) => + !string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml); + + /// + /// Canonicalizes and sanitizes rich-notes HTML accepted by Trip Editor mutation requests. + /// + public static string? NormalizeForPersistence(string? notesHtml) + { + if (string.IsNullOrWhiteSpace(notesHtml)) + { + return string.Empty; + } + + var canonicalImages = ImageSourceRegex.Replace(notesHtml.Trim(), match => + { + var source = CanonicalImageSource(match.Groups["url"].Value); + return $"{match.Groups["prefix"].Value}{WebUtility.HtmlEncode(source)}{match.Groups["suffix"].Value}"; + }); + + var document = Parser.ParseDocument(canonicalImages); + var body = document.Body; + if (body == null) + { + return string.Empty; + } + + foreach (var element in body.QuerySelectorAll("span.ql-ui").ToArray()) + { + element.Remove(); + } + + foreach (var element in body.QuerySelectorAll("*").Reverse().ToArray()) + { + NormalizeElement(element); + } + + RemoveTrailingBlankParagraphs(body); + var html = body.InnerHtml.Trim(); + return string.Equals(html, "


", StringComparison.OrdinalIgnoreCase) + ? string.Empty + : html; + } + + private static void NormalizeElement(IElement element) + { + if (RemovedTags.Contains(element.TagName)) + { + element.Remove(); + return; + } + + if (!AllowedTags.Contains(element.TagName)) + { + element.Replace(element.ChildNodes.ToArray()); + return; + } + + foreach (var attribute in element.Attributes.ToArray()) + { + if (!IsAllowedAttribute(element, attribute)) + { + element.RemoveAttribute(attribute.Name); + } + } + + if (string.Equals(element.TagName, "img", StringComparison.OrdinalIgnoreCase)) + { + NormalizeImage(element); + } + } + + private static bool IsAllowedAttribute(IElement element, IAttr attribute) + { + var name = attribute.Name.ToLowerInvariant(); + if (name == "class") + { + return NormalizeClassAttribute(element); + } + + if (name == "href" && string.Equals(element.TagName, "a", StringComparison.OrdinalIgnoreCase)) + { + return IsAllowedLink(attribute.Value); + } + + if (name == "src" && string.Equals(element.TagName, "img", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return name == "data-list" + && string.Equals(element.TagName, "li", StringComparison.OrdinalIgnoreCase) + && AllowedListKinds.Contains(attribute.Value); + } + + private static bool NormalizeClassAttribute(IElement element) + { + var allowed = element.ClassList.Where(className => AllowedClasses.Contains(className)).ToArray(); + if (allowed.Length == 0) + { + return false; + } + + element.SetAttribute("class", string.Join(" ", allowed)); + return true; + } + + private static void NormalizeImage(IElement element) + { + var source = CanonicalImageSource(element.GetAttribute("src") ?? string.Empty); + if (!IsAllowedAbsoluteHttpUrl(source)) + { + element.Remove(); + return; + } + + element.SetAttribute("src", source); + } + + private static bool IsAllowedLink(string value) + { + var compact = CompactUrlScheme(value); + return !compact.StartsWith("javascript:", StringComparison.Ordinal) + && !compact.StartsWith("data:", StringComparison.Ordinal) + && !compact.StartsWith("vbscript:", StringComparison.Ordinal); + } + + private static bool IsAllowedAbsoluteHttpUrl(string value) => + Uri.TryCreate(StripUrlBoundaryControls(value), UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + private static void RemoveTrailingBlankParagraphs(IElement body) + { + while (body.LastElementChild != null + && string.Equals(body.LastElementChild.TagName, "p", StringComparison.OrdinalIgnoreCase) + && IsBlankParagraph(body.LastElementChild)) + { + body.LastElementChild.Remove(); + } + } + + private static bool IsBlankParagraph(IElement element) + { + var text = (element.TextContent ?? string.Empty).Replace('\u00a0', ' '); + return string.IsNullOrWhiteSpace(text) + && element.QuerySelector("img") == null + && element.QuerySelector("video") == null + && element.QuerySelector("iframe") == null; + } + + private static string CanonicalImageSource(string value) + { + var trimmed = StripUrlBoundaryControls(WebUtility.HtmlDecode(value)); + if (!Uri.TryCreate(trimmed, UriKind.RelativeOrAbsolute, out var uri) + || !string.Equals(uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString.Split('?')[0], "/Public/ProxyImage", StringComparison.OrdinalIgnoreCase)) + { + return trimmed; + } + + var query = uri.IsAbsoluteUri ? uri.Query : new Uri(new Uri("https://wayfarer.local"), uri).Query; + var values = QueryHelpers.ParseQuery(query); + return values.TryGetValue("url", out var target) && target.Count > 0 + ? StripUrlBoundaryControls(target[0] ?? trimmed) + : trimmed; + } + + private static string StripUrlBoundaryControls(string value) => + Regex.Replace(value, @"^[\u0000-\u0020\u007f-\u009f]+|[\u0000-\u0020\u007f-\u009f]+$", string.Empty); + + private static string CompactUrlScheme(string value) => + Regex.Replace(StripUrlBoundaryControls(value)[..Math.Min(64, StripUrlBoundaryControls(value).Length)], @"[\u0000-\u0020\u007f-\u009f]+", string.Empty).ToLowerInvariant(); +} diff --git a/Models/Dtos/Editor/EditorSegmentRequestParser.cs b/Models/Dtos/Editor/EditorSegmentRequestParser.cs index cfdd9279..93038a77 100644 --- a/Models/Dtos/Editor/EditorSegmentRequestParser.cs +++ b/Models/Dtos/Editor/EditorSegmentRequestParser.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; using NetTopologySuite.Geometries; using Wayfarer.Services; @@ -11,10 +10,6 @@ namespace Wayfarer.Models.Dtos.Editor; internal static class EditorSegmentRequestParser { private static readonly string[] ServerOwnedFields = { "id", "tripId", "displayOrder", "capabilities" }; - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse a complete-draft segment save request. /// @@ -49,7 +44,7 @@ public static bool TryParseSave(JsonElement request, out EditorSegmentSaveReques CanonicalMode(mode), distance, duration, - notesHtml, + EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), route); return true; } @@ -288,7 +283,7 @@ private static string CanonicalMode(string? mode) => private static void ValidateNotes(string? notesHtml, Dictionary errors) { - if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml)) + if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml)) { errors["notesHtml"] = new[] { "Notes cannot contain data image sources." }; } diff --git a/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs b/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs index dfb6732a..1324d8e7 100644 --- a/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs +++ b/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; namespace Wayfarer.Models.Dtos.Editor; @@ -16,10 +15,6 @@ internal static class EditorTripMetadataUpdateRequestParser "updatedAt" }; - private static readonly Regex DataImageSourceRegex = new( - @"]*?\bsrc\s*=\s*[""']?\s*data:image/", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - /// /// Attempts to parse an editor metadata update request and returns field-keyed validation errors. /// @@ -55,7 +50,7 @@ public static bool TryParse( return false; } - update = new EditorTripMetadataUpdateRequest(name!, notesHtml, isPublic!.Value, coverImage, center, zoom); + update = new EditorTripMetadataUpdateRequest(name!, EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), isPublic!.Value, coverImage, center, zoom); return true; } @@ -267,7 +262,7 @@ private static void ValidateCoverImage(string? rawUrl, Dictionary errors) { - if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml)) + if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml)) { errors["notesHtml"] = new[] { "Notes images must use external image URLs, not data:image sources." }; } diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs index 6469f306..7464858d 100644 --- a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs @@ -78,6 +78,29 @@ public async Task PatchMetadataNullNotesStoresEmptyString() Assert.Equal(string.Empty, db.Trips.Single(t => t.Id == trip.Id).Notes); } + [Fact] + public async Task PatchMetadataNormalizesRichNotesBeforePersisting() + { + using var db = CreateDbContext(); + var trip = SeedTrip(db, "owner-user"); + var controller = BuildController(db); + ConfigureControllerWithUserRole(controller, "owner-user"); + const string expectedNotes = "

Centered

"; + + var result = await PatchMetadata(controller, trip.Id, ValidMetadataJson(notesHtml: """ + "

Centered


" + """), CancellationToken.None); + + var envelope = AssertMutation(result); + var stored = db.Trips.Single(t => t.Id == trip.Id); + Assert.Equal(expectedNotes, envelope.Data.NotesHtml); + Assert.Equal(expectedNotes, stored.Notes); + Assert.DoesNotContain("/Public/ProxyImage", stored.Notes); + Assert.DoesNotContain("onclick", stored.Notes); + Assert.DoesNotContain("onerror", stored.Notes); + Assert.DoesNotContain("


", stored.Notes); + } + [Theory] [InlineData("null")] [InlineData("""{ "rawUrl": null }""")] diff --git a/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs b/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs new file mode 100644 index 00000000..f542e999 --- /dev/null +++ b/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using Wayfarer.Models.Dtos.Editor; +using Xunit; + +namespace Wayfarer.Tests.Models; + +/// +/// Contract tests for Trip Editor rich-notes request HTML before persistence. +/// +public sealed class EditorRichNotesRequestHtmlTests +{ + [Fact] + public void NormalizeForPersistence_PreservesAllowedFormattingListsAlignmentLinksAndImages() + { + var input = string.Concat( + "

Bold Italic Underline

", + "
  1. Bullet
  2. ", + "
  3. Ordered
", + "

Left

", + "

Center

", + "

Right

", + "

Link

", + "

"); + + var result = EditorRichNotesRequestHtml.NormalizeForPersistence(input); + + Assert.Contains("Bold", result); + Assert.Contains("Italic", result); + Assert.Contains("Underline", result); + Assert.Contains("data-list=\"bullet\"", result); + Assert.Contains("data-list=\"ordered\"", result); + Assert.Contains("

Left

", result); + Assert.Contains("

Center

", result); + Assert.Contains("

Right

", result); + Assert.Contains("href=\"https://example.test/page\"", result); + Assert.Contains("src=\"https://cdn.example.test/image.jpg\"", result); + Assert.DoesNotContain("ql-align-left", result); + Assert.DoesNotContain("ql-ui", result); + Assert.DoesNotContain("onclick", result); + Assert.DoesNotContain("onerror", result); + } + + [Fact] + public void NormalizeForPersistence_UnwrapsProxyImageUrlsBeforeSave() + { + var result = EditorRichNotesRequestHtml.NormalizeForPersistence( + "

"); + + Assert.Contains("src=\"https://cdn.example.test/proxied.jpg\"", result); + Assert.DoesNotContain("/Public/ProxyImage", result); + } + + [Fact] + public void NormalizeForPersistence_StripsDataImagesUnsafeUrlsAndScripts() + { + var input = string.Concat( + "", + "

Safe text

", + "

Unsafe link

", + "

", + "

"); + + var result = EditorRichNotesRequestHtml.NormalizeForPersistence(input); + + Assert.Contains("Safe text", result); + Assert.Contains("Unsafe link", result); + Assert.DoesNotContain("

", "")] + [InlineData("

Real content


", "

Real content

")] + [InlineData("


", "

")] + public void NormalizeForPersistence_RemovesOnlyTrailingHelperBlankParagraphs(string input, string expected) + { + var result = EditorRichNotesRequestHtml.NormalizeForPersistence(input); + + Assert.Equal(expected, result); + } + + [Fact] + public void ContainsDataImageSource_DetectsDirectDataImageBeforeNormalization() + { + Assert.True(EditorRichNotesRequestHtml.ContainsDataImageSource("

")); + Assert.False(EditorRichNotesRequestHtml.ContainsDataImageSource("

")); + } + + [Fact] + public void SaveRequestParsersNormalizeRichNotesBeforeMutationServicesPersist() + { + const string notesHtml = "

Right


"; + const string expectedNotesHtml = "

Right

"; + + Assert.True(EditorTripMetadataUpdateRequestParser.TryParse(Json($$""" + { + "name": "Trip", + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "isPublic": false, + "coverImage": null, + "center": null, + "zoom": null + } + """), out var metadata, out _)); + Assert.Equal(expectedNotesHtml, metadata.NotesHtml); + + Assert.True(EditorRegionRequestParser.TryParseSave(Json($$""" + { + "name": "Region", + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "coverImage": null, + "center": null + } + """), out var region, out _)); + Assert.Equal(expectedNotesHtml, region.NotesHtml); + + Assert.True(EditorPlaceRequestParser.TryParseCreate(Json($$""" + { + "name": "Place", + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "address": null, + "location": null, + "iconName": "marker", + "markerColor": "bg-blue", + "reverseGeocode": false + } + """), new HashSet { "marker" }, new HashSet { "bg-blue" }, out var place, out _)); + Assert.Equal(expectedNotesHtml, place.NotesHtml); + + Assert.True(EditorAreaRequestParser.TryParseCreate(Json($$""" + { + "name": "Area", + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "fillHex": "#ff6600", + "geometry": { "type": "Polygon", "coordinates": [[[23,37],[24,37],[24,38],[23,37]]] } + } + """), out var area, out _)); + Assert.Equal(expectedNotesHtml, area.NotesHtml); + + Assert.True(EditorSegmentRequestParser.TryParseSave(Json($$""" + { + "fromPlaceId": null, + "toPlaceId": null, + "mode": "walk", + "estimatedDistanceKm": null, + "estimatedDurationMinutes": null, + "notesHtml": "{{JsonEncodedText(notesHtml)}}", + "route": null + } + """), out var segment, out _)); + Assert.Equal(expectedNotesHtml, segment.NotesHtml); + } + + private static JsonElement Json(string json) => JsonDocument.Parse(json).RootElement.Clone(); + + private static string JsonEncodedText(string value) => System.Text.Json.JsonEncodedText.Encode(value).ToString(); +} diff --git a/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts b/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts new file mode 100644 index 00000000..8915c1e1 --- /dev/null +++ b/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; +import { + absoluteUrl, + editorApiPath, + editorPath, + expectMountedWorkspace, + loadEditorStateFixture, + signIn, + uniqueName +} from './tripEditorTestUtils'; + +type EditorState = Record; + +test.describe.serial('Trip Editor rich notes real persistence contract', () => { + test('metadata rich notes save through the real endpoint and reload as canonical HTML', async ({ page }) => { + await signIn(page); + await page.goto(absoluteUrl(editorPath)); + await expectMountedWorkspace(page); + const originalState = await loadEditorStateFixture(page) as EditorState; + const originalMetadata = { ...originalState.metadata }; + const imageUrl = 'https://images.example.test/rich-notes-persistence.png'; + const runText = uniqueName('PW rich notes persisted'); + await routeProxyImage(page, imageUrl); + + try { + const form = page.locator('#trip-editor-metadata-form'); + const editor = richEditor(form).locator('.ql-editor'); + await editor.click(); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(runText); + await page.keyboard.press('Enter'); + await richEditor(form).locator('button.ql-align[value="center"]').click(); + await page.keyboard.type('Centered persisted note'); + await insertImageUrl(form, imageUrl); + + await page.getByRole('button', { name: 'Save & Continue' }).click(); + await expectSaved(page); + + await expectState(page, state => { + const notes = state.metadata.notesHtml as string; + expect(notes).toContain(runText); + expect(notes).toContain('

Centered persisted note'); + expect(notes).toContain(`src="${imageUrl}"`); + expect(notes).not.toContain('/Public/ProxyImage'); + expect(notes).not.toContain('


'); + }); + + await page.reload(); + await expectMountedWorkspace(page); + const reloadedForm = page.locator('#trip-editor-metadata-form'); + const reloadedEditor = richEditor(reloadedForm).locator('.ql-editor'); + await expect(reloadedEditor).toContainText(runText); + await expect(reloadedEditor.locator('p.ql-align-center')).toContainText('Centered persisted note'); + await expect(reloadedEditor.locator('img')).toHaveAttribute('src', /\/Public\/ProxyImage\?url=https%3A%2F%2Fimages\.example\.test%2Frich-notes-persistence\.png$/); + } finally { + if (!page.isClosed()) { + await restoreMetadata(page, originalMetadata); + } + } + }); +}); + +async function routeProxyImage(page: Page, url: string): Promise { + const escaped = encodeURIComponent(url).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await page.route(new RegExp(`/Public/ProxyImage\\?url=${escaped}$`, 'i'), async route => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: 'Rich notes persistence' + }); + }); +} + +function richEditor(form: Locator): Locator { + return form.locator('.trip-editor-rich-notes'); +} + +async function insertImageUrl(form: Locator, url: string): Promise { + await richEditor(form).locator('.ql-image').click(); + const dialog = form.page().getByRole('dialog', { name: 'Insert image URL' }); + await expect(dialog).toBeVisible(); + await dialog.getByLabel('Image URL').fill(url); + await dialog.getByRole('button', { name: 'Insert Image' }).click(); + await expect(dialog).toHaveCount(0); +} + +async function expectSaved(page: Page): Promise { + await expect(page.locator('.trip-editor-save-state').filter({ hasText: /saved/i }).first()).toBeVisible(); +} + +async function expectState(page: Page, assertion: (state: EditorState) => void): Promise { + assertion(await loadEditorStateFixture(page) as EditorState); +} + +async function restoreMetadata(page: Page, metadata: Record): Promise { + const token = await page.locator('#trip-editor-antiforgery input[name="__RequestVerificationToken"]').inputValue().catch(() => ''); + if (!token) { + return; + } + + const response = await page.request.patch(absoluteUrl(`${editorApiPath}/metadata`), { + data: { + name: metadata.name, + notesHtml: metadata.notesHtml, + isPublic: metadata.isPublic, + coverImage: metadata.coverImage, + center: metadata.center, + zoom: metadata.zoom + }, + headers: { RequestVerificationToken: token } + }); + expect(response.ok(), `metadata cleanup PATCH returned ${response.status()}: ${await response.text()}`).toBeTruthy(); +} From 954a8bb3c83e00551ead7e9361dfc97dc7e4f33d Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 18 May 2026 13:11:39 +0300 Subject: [PATCH 3/5] WIP: stabilize rich notes persistence validation (checkpoint) --- tests/e2e/trip-editor/tripEditorRichNotes.spec.ts | 13 +++++++++++++ .../tripEditorRichNotesPersistence.spec.ts | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts b/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts index ef3c757b..d52afc43 100644 --- a/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts +++ b/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts @@ -61,6 +61,7 @@ test.describe.serial('Trip Editor rich notes parity', () => { await page.keyboard.press('Enter'); await metadataEditor.locator('button.ql-align[value="right"]').click(); await page.keyboard.type('Right aligned note'); + await routeImageUrl(page, 'https://example.com/photo.jpg'); await insertImageUrl(metadataForm, 'https://example.com/photo.jpg'); await page.getByRole('button', { name: 'Save & Continue' }).click(); await expect.poll(() => requests.length).toBe(1); @@ -144,6 +145,7 @@ test.describe.serial('Trip Editor rich notes parity', () => { await loadWorkspaceWithRichNotesFixture(page); const form = page.locator('#trip-editor-metadata-form'); + await routeImageUrl(page, 'https://images.example.test/rich-note.png'); await insertImageUrl(form, 'https://images.example.test/rich-note.png'); const image = richEditor(form).locator('.ql-editor img'); await expect(image).toHaveAttribute('src', /\/Public\/ProxyImage\?url=https%3A%2F%2Fimages\.example\.test%2Frich-note\.png$/); @@ -403,6 +405,17 @@ async function routeRichNoteImages(page: Page): Promise { }); } +async function routeImageUrl(page: Page, url: string): Promise { + const escaped = encodeURIComponent(url).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await page.route(new RegExp(`/Public/ProxyImage\\?url=${escaped}$`, 'i'), async route => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '' + }); + }); +} + async function routeEditorMutations(page: Page, state: MutableEditorState, requests: Array<{ method: string; url: string; body: Record }>): Promise { await page.unroute(editorApiMatcher); // Mocked mutation routes keep rich-editor behavior deterministic; pair with backend/real endpoint coverage for CRUD proof. diff --git a/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts b/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts index 8915c1e1..89ea55cd 100644 --- a/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts +++ b/tests/e2e/trip-editor/tripEditorRichNotesPersistence.spec.ts @@ -33,8 +33,13 @@ test.describe.serial('Trip Editor rich notes real persistence contract', () => { await richEditor(form).locator('button.ql-align[value="center"]').click(); await page.keyboard.type('Centered persisted note'); await insertImageUrl(form, imageUrl); + await expect(editor).toContainText(runText); + await expect(editor.locator('p.ql-align-center')).toContainText('Centered persisted note'); + const saveResponse = page.waitForResponse(response => + response.url().endsWith(`${editorApiPath}/metadata`) && response.request().method() === 'PATCH'); await page.getByRole('button', { name: 'Save & Continue' }).click(); + expect((await saveResponse).ok()).toBeTruthy(); await expectSaved(page); await expectState(page, state => { @@ -51,7 +56,7 @@ test.describe.serial('Trip Editor rich notes real persistence contract', () => { const reloadedForm = page.locator('#trip-editor-metadata-form'); const reloadedEditor = richEditor(reloadedForm).locator('.ql-editor'); await expect(reloadedEditor).toContainText(runText); - await expect(reloadedEditor.locator('p.ql-align-center')).toContainText('Centered persisted note'); + await expect(reloadedEditor.locator('p.ql-align-center').filter({ hasText: 'Centered persisted note' })).toBeVisible(); await expect(reloadedEditor.locator('img')).toHaveAttribute('src', /\/Public\/ProxyImage\?url=https%3A%2F%2Fimages\.example\.test%2Frich-notes-persistence\.png$/); } finally { if (!page.isClosed()) { From d5f928a27fa82d335144316609c59edbb549e4c3 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 18 May 2026 13:15:41 +0300 Subject: [PATCH 4/5] WIP: trim rich notes spec under LOC cap (checkpoint) --- .../trip-editor/tripEditorRichNotes.spec.ts | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts b/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts index d52afc43..ec1b006d 100644 --- a/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts +++ b/tests/e2e/trip-editor/tripEditorRichNotes.spec.ts @@ -61,7 +61,7 @@ test.describe.serial('Trip Editor rich notes parity', () => { await page.keyboard.press('Enter'); await metadataEditor.locator('button.ql-align[value="right"]').click(); await page.keyboard.type('Right aligned note'); - await routeImageUrl(page, 'https://example.com/photo.jpg'); + await routeRichNoteImages(page, 'https://example.com/photo.jpg'); await insertImageUrl(metadataForm, 'https://example.com/photo.jpg'); await page.getByRole('button', { name: 'Save & Continue' }).click(); await expect.poll(() => requests.length).toBe(1); @@ -145,7 +145,7 @@ test.describe.serial('Trip Editor rich notes parity', () => { await loadWorkspaceWithRichNotesFixture(page); const form = page.locator('#trip-editor-metadata-form'); - await routeImageUrl(page, 'https://images.example.test/rich-note.png'); + await routeRichNoteImages(page, 'https://images.example.test/rich-note.png'); await insertImageUrl(form, 'https://images.example.test/rich-note.png'); const image = richEditor(form).locator('.ql-editor img'); await expect(image).toHaveAttribute('src', /\/Public\/ProxyImage\?url=https%3A%2F%2Fimages\.example\.test%2Frich-note\.png$/); @@ -395,25 +395,10 @@ function terminalImageNotes(): string { return '

Intro before image

'; } -async function routeRichNoteImages(page: Page): Promise { - await page.route(/\/Public\/ProxyImage\?url=https%3A%2F%2Fimages\.example\.test%2Fterminal-large\.svg$/i, async route => { - await route.fulfill({ - status: 200, - contentType: 'image/svg+xml', - body: 'Terminal rich note image' - }); - }); -} - -async function routeImageUrl(page: Page, url: string): Promise { - const escaped = encodeURIComponent(url).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - await page.route(new RegExp(`/Public/ProxyImage\\?url=${escaped}$`, 'i'), async route => { - await route.fulfill({ - status: 200, - contentType: 'image/svg+xml', - body: '' - }); - }); +async function routeRichNoteImages(page: Page, ...urls: string[]): Promise { + for (const url of ['https://images.example.test/terminal-large.svg', ...urls]) { + await page.route(new RegExp(`/Public/ProxyImage\\?url=${encodeURIComponent(url).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'), route => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '' })); + } } async function routeEditorMutations(page: Page, state: MutableEditorState, requests: Array<{ method: string; url: string; body: Record }>): Promise { From b689496e3fcca172bb536e2d3529484e6be2e147 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 18 May 2026 17:48:37 +0300 Subject: [PATCH 5/5] WIP: restrict rich notes class normalization (checkpoint) --- .../Dtos/Editor/EditorRichNotesRequestHtml.cs | 20 ++++++++++++++++--- .../Models/EditorRichNotesRequestHtmlTests.cs | 14 +++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs b/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs index a9742986..cdf8d86e 100644 --- a/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs +++ b/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs @@ -30,9 +30,19 @@ internal static class EditorRichNotesRequestHtml "base", "button", "embed", "form", "iframe", "input", "link", "meta", "object", "option", "script", "select", "style", "textarea" }; - private static readonly HashSet AllowedClasses = new(StringComparer.Ordinal) + private static readonly HashSet AllowedAlignmentClasses = new(StringComparer.Ordinal) { - "ql-align-center", "ql-align-right", "ql-font-monospace", "ql-font-serif" + "ql-align-center", "ql-align-right" + }; + + private static readonly HashSet AllowedFontClasses = new(StringComparer.Ordinal) + { + "ql-font-monospace", "ql-font-serif" + }; + + private static readonly HashSet QuillBlockTags = new(StringComparer.OrdinalIgnoreCase) + { + "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p" }; private static readonly HashSet AllowedListKinds = new(StringComparer.Ordinal) @@ -139,7 +149,7 @@ private static bool IsAllowedAttribute(IElement element, IAttr attribute) private static bool NormalizeClassAttribute(IElement element) { - var allowed = element.ClassList.Where(className => AllowedClasses.Contains(className)).ToArray(); + var allowed = element.ClassList.Where(className => IsAllowedClass(element, className)).ToArray(); if (allowed.Length == 0) { return false; @@ -149,6 +159,10 @@ private static bool NormalizeClassAttribute(IElement element) return true; } + private static bool IsAllowedClass(IElement element, string className) => + (string.Equals(element.TagName, "span", StringComparison.OrdinalIgnoreCase) && AllowedFontClasses.Contains(className)) + || (QuillBlockTags.Contains(element.TagName) && AllowedAlignmentClasses.Contains(className)); + private static void NormalizeImage(IElement element) { var source = CanonicalImageSource(element.GetAttribute("src") ?? string.Empty); diff --git a/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs b/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs index f542e999..9562eb7b 100644 --- a/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs +++ b/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs @@ -19,6 +19,7 @@ public void NormalizeForPersistence_PreservesAllowedFormattingListsAlignmentLink "

Left

", "

Center

", "

Right

", + "

Serif

", "

Link

", "

"); @@ -32,6 +33,7 @@ public void NormalizeForPersistence_PreservesAllowedFormattingListsAlignmentLink Assert.Contains("

Left

", result); Assert.Contains("

Center

", result); Assert.Contains("

Right

", result); + Assert.Contains("Serif", result); Assert.Contains("href=\"https://example.test/page\"", result); Assert.Contains("src=\"https://cdn.example.test/image.jpg\"", result); Assert.DoesNotContain("ql-align-left", result); @@ -40,6 +42,18 @@ public void NormalizeForPersistence_PreservesAllowedFormattingListsAlignmentLink Assert.DoesNotContain("onerror", result); } + [Fact] + public void NormalizeForPersistence_StripsQuillClassesFromUnsupportedElements() + { + var result = EditorRichNotesRequestHtml.NormalizeForPersistence( + "

Inline alignment

Block font

"); + + Assert.Contains("Inline alignment", result); + Assert.Contains("

Block font

", result); + Assert.DoesNotContain("ql-align-right", result); + Assert.DoesNotContain("ql-font-serif", result); + } + [Fact] public void NormalizeForPersistence_UnwrapsProxyImageUrlsBeforeSave() {