diff --git a/apps/mobile/src/mobileEditorDraft.test.ts b/apps/mobile/src/mobileEditorDraft.test.ts index bfccc4fc..1769daa3 100644 --- a/apps/mobile/src/mobileEditorDraft.test.ts +++ b/apps/mobile/src/mobileEditorDraft.test.ts @@ -80,6 +80,32 @@ describe('mobile editor draft', () => { }) }) + it('serializes safe link destinations and decodes escaped link URLs', () => { + const draft = createMobileEditorDraft({ + note: { + id: 'links', + title: 'Links', + content: '# Links', + }, + editorHtml: [ + '
', + '', + '', + ].join(''), + }) + + expect(draft).toMatchObject({ + persistable: true, + canonicalMarkdown: [ + '[Website](https://tolaria.app?ref=notes&device=ios)', + '', + '[Email](mailto:hello@tolaria.app)', + '', + '[Relative note](notes/workflow.md)', + ].join('\n'), + }) + }) + it('serializes blockquotes, code blocks, and strikethrough', () => { const draft = createMobileEditorDraft({ note: { @@ -149,6 +175,23 @@ describe('mobile editor draft', () => { }) }) + it('blocks unsafe link destinations instead of persisting risky Markdown', () => { + expect( + createMobileEditorDraft({ + note: { + id: 'links', + title: 'Links', + content: '# Links', + }, + editorHtml: '', + }), + ).toMatchObject({ + noteId: 'links', + persistable: false, + blockedReason: 'unsupportedEditorHtml', + }) + }) + it('serializes simple TenTap tables as Markdown tables', () => { expect( createMobileEditorDraft({ diff --git a/apps/mobile/src/mobileEditorHtmlMarkdown.ts b/apps/mobile/src/mobileEditorHtmlMarkdown.ts index e9da0aec..e408b130 100644 --- a/apps/mobile/src/mobileEditorHtmlMarkdown.ts +++ b/apps/mobile/src/mobileEditorHtmlMarkdown.ts @@ -185,7 +185,7 @@ function markInlineHtml(input: HtmlInput) { .replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*') .replace(/<(s|strike|del)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '~~$2~~') .replace(/]*)?>([\s\S]*?)<\/code>/gi, '`$1`')
- .replace(/]*)?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
+ .replace(/]*>([\s\S]*?)<\/a>/gi, (tag, label) => linkMarkdown({ tag, label }) ?? tag)
}
function containsUnsupportedTag(input: HtmlInput) {
@@ -194,13 +194,17 @@ function containsUnsupportedTag(input: HtmlInput) {
}
function blocksUnsafeEditorOutput(input: HtmlInput) {
- return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeTable(input)
+ return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeLink(input) || containsUnsafeTable(input)
}
function containsUnsafeImage(input: HtmlInput) {
return [...input.html.matchAll(/
]*>/gi)].some((match) => !imageMarkdown({ tag: match[0] }))
}
+function containsUnsafeLink(input: HtmlInput) {
+ return [...input.html.matchAll(/]*>/gi)].some((match) => !linkMarkdown({ tag: match[0], label: '' }))
+}
+
function containsUnsafeTable(input: HtmlInput) {
return Boolean(isMobileEditorTableBlock(input)) && !canSerializeMobileEditorTable(input)
}
@@ -220,6 +224,16 @@ function imageSource(input: { tag: string }) {
return src && isPersistableImageSource({ src }) ? src : null
}
+function linkMarkdown(input: { tag: string; label: string }) {
+ const href = linkDestination(input)
+ return href ? `[${input.label}](${href})` : null
+}
+
+function linkDestination(input: { tag: string }) {
+ const href = htmlAttribute({ tag: input.tag, name: 'href' })
+ return href && isPersistableLinkDestination({ href }) ? decodeHtmlEntities({ text: href }) : null
+}
+
function htmlAttribute(input: { tag: string; name: string }) {
const match = input.tag.match(new RegExp(`${input.name}=["']([^"']+)["']`, 'i'))
return match?.[1] ?? null
@@ -241,6 +255,27 @@ function isRelativeImageSource(input: { src: string }) {
return !input.src.startsWith('/') && !input.src.startsWith('//') && !input.src.match(/^[A-Za-z][A-Za-z0-9+.-]*:/)
}
+function isPersistableLinkDestination(input: { href: string }) {
+ const href = decodeHtmlEntities({ text: input.href })
+ if (href.match(/[\n\r]/)) {
+ return false
+ }
+
+ return isRemoteLinkDestination({ href }) || isMailLinkDestination({ href }) || isRelativeLinkDestination({ href })
+}
+
+function isRemoteLinkDestination(input: { href: string }) {
+ return input.href.startsWith('https://') || input.href.startsWith('http://')
+}
+
+function isMailLinkDestination(input: { href: string }) {
+ return input.href.startsWith('mailto:')
+}
+
+function isRelativeLinkDestination(input: { href: string }) {
+ return !input.href.startsWith('/') && !input.href.startsWith('//') && !input.href.match(/^[A-Za-z][A-Za-z0-9+.-]*:/)
+}
+
function stripRemainingTags(value: string) {
return value.replace(/<[^>]+>/g, '')
}
diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md
index 126437cf..d1eef4df 100644
--- a/docs/MOBILE_PROGRESS.md
+++ b/docs/MOBILE_PROGRESS.md
@@ -98,6 +98,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Separated the mobile Git remote setup prompt styles from note-creation prompt styles so vault-management UI can evolve without coupling to compose UI names.
- Added a sidebar vault-management card that shows the active app-local vault, Git sync state, and an explicit Git remote action on iPad and compact sidebar surfaces.
- Added the first mobile Git transport execution boundary behind the existing sync/auth plan, including explicit pull/push routing and a visible unavailable-transport failure until the native Git implementation lands.
+- Hardened TenTap link serialization so safe HTTP, mailto, and relative links persist while unsafe link destinations block draft persistence.
## Next Action
@@ -371,6 +372,10 @@ Continue Phase 4 with editor durability:
- `pnpm --filter @tolaria/mobile typecheck` passed after adding the mobile Git transport execution boundary.
- CodeScene after adding the mobile Git transport execution boundary: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/useMobileGitSyncFlow.ts`, `apps/mobile/src/mobileGitSyncPlan.ts`, `apps/mobile/src/mobileGitPrimaryAction.ts`, `apps/mobile/src/mobileGitPrimaryAction.test.ts`, `apps/mobile/src/mobileGitTransport.ts`, `apps/mobile/src/mobileGitTransport.test.ts`, `apps/mobile/src/mobileGitSyncFlowAction.ts`, and `apps/mobile/src/mobileGitSyncFlowAction.test.ts` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after adding the mobile Git transport execution boundary.
+- `pnpm --filter @tolaria/mobile test -- src/mobileEditorDraft.test.ts src/mobileEditorDraftSave.test.ts` passed after hardening TenTap link serialization: 43 files / 145 tests.
+- `pnpm --filter @tolaria/mobile typecheck` passed after hardening TenTap link serialization.
+- CodeScene after hardening TenTap link serialization: `apps/mobile/src/mobileEditorHtmlMarkdown.ts` and `apps/mobile/src/mobileEditorDraft.test.ts` scored `10`.
+- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after hardening TenTap link serialization.
## Risks / Watch Items