Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 53 additions & 9 deletions packages/web/__tests__/share-url.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { describe, expect, it } from 'vitest'
import LZString from 'lz-string'
import YAML from 'yaml'
import { deflateSync } from 'fflate'
import { parseFlowYaml } from '@openhop/shared'
import { buildShareUrl, decodeFragment, encodeFragment } from '../src/lib/share-url'

function toBase64Url(bytes: Uint8Array): string {
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
// btoa is available in vitest's jsdom env
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

const SAMPLE_YAML = `meta:
title: Round-trip
path: demos
Expand All @@ -20,39 +30,73 @@ flow:
`

describe('share-url encode/decode', () => {
it('round-trips a typical flow', () => {
it('round-trips a typical flow (semantically — minification rewrites whitespace)', () => {
const enc = encodeFragment(SAMPLE_YAML)
expect(enc).not.toContain(' ')
expect(enc).not.toContain('\n')
const dec = decodeFragment(enc)
expect(dec).toBe(SAMPLE_YAML)
// And the round-tripped YAML still validates against the schema.
expect(dec).not.toBeNull()
// YAML stringify may pick a different indent / quoting style, so compare
// the parsed objects rather than the raw text.
expect(YAML.parse(dec!)).toEqual(YAML.parse(SAMPLE_YAML))
expect(parseFlowYaml(dec ?? '').success).toBe(true)
})

it('compresses well — typical flow encodes to fewer than the raw byte count', () => {
// Locks in that the lz-string variant is actually denser than the raw
// bytes for representative input. (URL-safe base64 of raw text would be
// ~1.34× — lz-string typically wins by ~3× on YAML's repetitive shape.)
it('emits the v1 prefix (~1) for new shares', () => {
expect(encodeFragment(SAMPLE_YAML).startsWith('~1')).toBe(true)
})

it('compresses meaningfully — encoded form is shorter than the raw YAML', () => {
const enc = encodeFragment(SAMPLE_YAML)
expect(enc.length).toBeLessThan(SAMPLE_YAML.length)
})

it('new format is shorter than the legacy lz-string form for typical input', () => {
const v1 = encodeFragment(SAMPLE_YAML)
const v0 = LZString.compressToEncodedURIComponent(SAMPLE_YAML)
expect(v1.length).toBeLessThan(v0.length)
})

it('still decodes legacy lz-string fragments (backward compat for old share URLs)', () => {
const legacy = LZString.compressToEncodedURIComponent(SAMPLE_YAML)
const dec = decodeFragment(legacy)
// Legacy v0 returns the YAML verbatim (no minify step on decode), so
// string-equality holds in this direction.
expect(dec).toBe(SAMPLE_YAML)
})

it('decodes empty / missing fragment to null', () => {
expect(decodeFragment('')).toBeNull()
})

it('decodes garbage to null (caller renders the corrupted-link banner)', () => {
expect(decodeFragment('this-is-not-lz-encoded')).toBeNull()
expect(decodeFragment('!!!~~~')).toBeNull()
// v1 prefix with junk after it — the base64 might decode but inflate
// will fail.
expect(decodeFragment('~1!!!not-base64!!!')).toBeNull()
})

it('refuses oversized v1 fragments (compressed-input cap)', () => {
// 80 KB of random-ish bytes; well above the 64 KB compressed cap. Doesn't
// even need to be a real DEFLATE stream — the length check fires first.
const bomb = new Uint8Array(80 * 1024).map((_, i) => (i * 31) & 0xff)
expect(decodeFragment('~1' + toBase64Url(bomb))).toBeNull()
})

it('refuses decompression bombs (inflated-output cap)', () => {
// 2 MB of zeros DEFLATE-compresses to ~2 KB — fits under the input cap
// but blows past the 1 MB inflated cap.
const oversized = deflateSync(new Uint8Array(2 * 1024 * 1024), { level: 9 })
expect(decodeFragment('~1' + toBase64Url(oversized))).toBeNull()
})

it('buildShareUrl uses Vite BASE_URL so dev (/) and Pages (/openhop/) both work', () => {
const dev = buildShareUrl(SAMPLE_YAML, 'http://localhost:8788', '/')
expect(dev).toMatch(/^http:\/\/localhost:8788\/#[A-Za-z0-9_+\-$.]+$/)
expect(dev).toMatch(/^http:\/\/localhost:8788\/#~1[A-Za-z0-9_-]+$/)

const pages = buildShareUrl(SAMPLE_YAML, 'https://naorsabag.github.io', '/openhop/')
expect(pages).toMatch(/^https:\/\/naorsabag\.github\.io\/openhop\/#[A-Za-z0-9_+\-$.]+$/)
expect(pages).toMatch(/^https:\/\/naorsabag\.github\.io\/openhop\/#~1[A-Za-z0-9_-]+$/)

// Hash content matches encodeFragment for both URLs (proves the BASE_URL
// only affects the path, never the encoded payload).
Expand Down
3 changes: 3 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,8 @@
"vite": "^8.0.11",
"vitest": "^1.6.1",
"yaml": "^2.8.4"
},
"dependencies": {
"fflate": "^0.8.2"
}
}
89 changes: 84 additions & 5 deletions packages/web/src/lib/share-url.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,102 @@
import LZString from 'lz-string'
import { deflateSync, inflateSync } from 'fflate'
import YAML from 'yaml'

/**
* URL-fragment encoding for the GitHub Pages deploy.
*
* The static deploy has no API, so a flow's full YAML lives in the URL hash:
* https://naorsabag.github.io/OpenHop/#<lz-uri-encoded>
* https://naorsabag.github.io/openhop/#<encoded>
*
* `compressToEncodedURIComponent` is the LZ variant designed for URLs — its
* output is already safe to drop straight into a hash without further encoding.
* Decompresses to `null` on bad input (truncated link, foreign payload, etc.),
* Two on-the-wire formats coexist; the leading byte(s) of the fragment tell
* the decoder which one to use:
*
* ~1<base64url> — version 1: YAML parsed + restringified with minimal
* indentation (drops comments/whitespace), DEFLATE-raw
* compressed, base64url-encoded. ~30-50% shorter than v0.
* <anything> — legacy: lz-string's URL-safe variant of the raw YAML.
*
* `~` is outside lz-string's output alphabet
* (`[A-Za-z0-9$_-]`), so the version prefix is unambiguous and old share
* URLs in the wild keep working.
*
* Bad input (truncated link, foreign payload, etc.) round-trips to `null`,
* which the app surfaces as a "share link looks corrupted" banner.
*/

const V1_PREFIX = '~1'

// Decompression-bomb guards. fflate has no streaming-abort hook, so we cap
// in two places: the compressed input length (which bounds worst-case
// allocation at MAX_INFLATED_BYTES via fflate, regardless of the DEFLATE
// expansion ratio attempted), and the inflated output (post-hoc, soft cap).
// Real example flows compress to ~3 KB / inflate to ~9 KB at the high end —
// the limits below leave 20-100x headroom for legitimate inputs while
// refusing pathological share URLs.
const MAX_FRAGMENT_BYTES = 64 * 1024
const MAX_INFLATED_BYTES = 1 * 1024 * 1024
// Pre-decode string-length cap. Base64url packs 3 bytes into 4 chars
// (no padding here), so N bytes ≤ MAX_FRAGMENT_BYTES ⇒ encoded length
// ≤ ⌈N/3⌉ × 4. Checking this first lets us reject a multi-megabyte
// hash in constant time, without atob/copy allocating the intermediate
// binary string.
const MAX_FRAGMENT_BASE64URL_CHARS = Math.ceil(MAX_FRAGMENT_BYTES / 3) * 4

function bytesToBase64Url(bytes: Uint8Array): string {
// btoa() needs a binary string, not a Uint8Array.
let binary = ''
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

function base64UrlToBytes(input: string): Uint8Array {
let s = input.replace(/-/g, '+').replace(/_/g, '/')
while (s.length % 4 !== 0) s += '='
const binary = atob(s)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
return out
}

/**
* Drop comments and re-emit with 1-space indent + no line wrapping. Reduces
* the byte count fed to DEFLATE by ~20-40% on the bundled example flows.
* Falls through to the raw text if the input doesn't parse (the editor may
* call us mid-keystroke on invalid YAML).
*/
function minifyYaml(yamlText: string): string {
try {
const parsed = YAML.parse(yamlText)
if (parsed === undefined) return yamlText
return YAML.stringify(parsed, { indent: 1, lineWidth: 0, minContentWidth: 0 })
} catch {
return yamlText
}
}

export function encodeFragment(yamlText: string): string {
return LZString.compressToEncodedURIComponent(yamlText)
const minified = minifyYaml(yamlText)
const compressed = deflateSync(new TextEncoder().encode(minified), { level: 9 })
return V1_PREFIX + bytesToBase64Url(compressed)
}

export function decodeFragment(fragment: string): string | null {
if (!fragment) return null
if (fragment.startsWith(V1_PREFIX)) {
try {
const payload = fragment.slice(V1_PREFIX.length)
if (payload.length > MAX_FRAGMENT_BASE64URL_CHARS) return null
const bytes = base64UrlToBytes(payload)
if (bytes.length > MAX_FRAGMENT_BYTES) return null
const inflated = inflateSync(bytes)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (inflated.length > MAX_INFLATED_BYTES) return null
const out = new TextDecoder().decode(inflated)
return out.length > 0 ? out : null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
return null
}
}
// Legacy v0 — lz-string of the raw YAML. Keeps old share URLs working.
try {
const out = LZString.decompressFromEncodedURIComponent(fragment)
return out && out.length > 0 ? out : null
Expand Down
Loading