Skip to content

Commit 7402164

Browse files
committed
feat(copilot): support ?url= to load a specific editor URL
Add a ?url= query param whose value becomes the editor iframe src verbatim and the postMessage bridge target origin, so a fork can open any document / tenant / editor params instead of the bundled demo forms. Restrict it to the configured base-domain host family: third-party origins are rejected so an arbitrary site can't be framed with the iframe's clipboard permissions or wired to the bridge. Absent, malformed, or off-domain values fall back to the default demo-form behaviour. Also hoist the client-env block above the route helpers so the new validator can read BASE_DOMAIN_URL, and fix requiresUserUpload to stay false when ?url= supplies the document.
1 parent edc3661 commit 7402164

2 files changed

Lines changed: 101 additions & 52 deletions

File tree

copilot/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ Two providers are supported on the shared-key path:
119119

120120
See [`.env.example`](./.env.example) for the JSON shape, the per-share rate-limit options, and the portable base64 one-liner for hosts that mangle embedded quotes (DigitalOcean App Platform, Render, fly.io). Then visit `http://localhost:3001/?share=<id>` and you're set.
121121

122+
### Load a specific document via `?url=`
123+
124+
To open a specific editor URL instead of the bundled demo forms, append `?url=<editor-url>`. The value is used verbatim as the editor iframe `src`, so pass a full SimplePDF editor URL with whatever it needs (`?open=`, a locale prefix, signature-request params, a different tenant subdomain):
125+
126+
```
127+
http://localhost:3001/?url=https%3A%2F%2Fspdf-copilot.simplepdf.com%2Feditor
128+
```
129+
130+
URL-encode the value whenever it carries its own query string (e.g. `?open=`), otherwise the nested params get parsed as part of the page URL and dropped.
131+
132+
The value must be an absolute `http(s)` URL on your configured base-domain family (`*.simplepdf.com` by default, or whatever host `VITE_SIMPLEPDF_BASE_DOMAIN` resolves to). Third-party origins are rejected on purpose: the iframe is granted clipboard access and is wired to the `postMessage` bridge, so framing an arbitrary site would hand it both. A malformed or off-domain `?url=` silently falls back to the default demo form. `?url=` combines with `?lang=` and `?share=`; when set, it wins for what the editor loads.
133+
122134
### Ship it on your own domain
123135

124136
Running SimplePDF Copilot anywhere other than `localhost:3001` or the hosted demo URL requires a SimplePDF [Pro](https://simplepdf.com/pricing) account (or higher) so that:

copilot/src/routes/index.tsx

Lines changed: 89 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,47 @@ import {
2121

2222
export type { DemoGate }
2323

24+
// Trim incoming env strings, treat empty as missing. Outputs `string | undefined`.
25+
const TrimmedOptionalString = z.preprocess((val) => {
26+
if (typeof val !== 'string') {
27+
return undefined
28+
}
29+
const trimmed = val.trim()
30+
return trimmed === '' ? undefined : trimmed
31+
}, z.string().min(1).optional())
32+
33+
// The company identifier is the only required env var. A missing value MUST
34+
// fail loudly at startup instead of silently pointing at production SimplePDF
35+
// with the demo's shared identifier (which would either succeed and bill the
36+
// demo workspace, or fail at iframe-load time with a confusing whitelist
37+
// error). Base domain is optional with a default of https://simplepdf.com;
38+
// override for staging, alternate prod tenants, or a local SimplePDF dev
39+
// checkout.
40+
const ClientEnvSchema = z.object({
41+
VITE_SIMPLEPDF_COMPANY_IDENTIFIER: z.preprocess(
42+
(val) => (typeof val === 'string' ? val.trim() : val),
43+
z.string().min(1, 'VITE_SIMPLEPDF_COMPANY_IDENTIFIER is required (see .env.example)'),
44+
),
45+
VITE_SIMPLEPDF_BASE_DOMAIN: TrimmedOptionalString.pipe(z.url().optional()),
46+
})
47+
48+
const DEFAULT_BASE_DOMAIN = 'https://simplepdf.com'
49+
50+
const clientEnv = ((): z.infer<typeof ClientEnvSchema> => {
51+
const result = ClientEnvSchema.safeParse({
52+
VITE_SIMPLEPDF_COMPANY_IDENTIFIER: import.meta.env.VITE_SIMPLEPDF_COMPANY_IDENTIFIER,
53+
VITE_SIMPLEPDF_BASE_DOMAIN: import.meta.env.VITE_SIMPLEPDF_BASE_DOMAIN,
54+
})
55+
if (!result.success) {
56+
throw new Error(`Client env invalid:\n${z.prettifyError(result.error)}`)
57+
}
58+
return result.data
59+
})()
60+
61+
const COMPANY_IDENTIFIER = clientEnv.VITE_SIMPLEPDF_COMPANY_IDENTIFIER
62+
const BASE_DOMAIN_URL = new URL(clientEnv.VITE_SIMPLEPDF_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN)
63+
const EDITOR_ORIGIN = `${BASE_DOMAIN_URL.protocol}//${COMPANY_IDENTIFIER}.${BASE_DOMAIN_URL.host}`
64+
2465
// Tuple-derived union so adding / removing a value updates the type, the
2566
// runtime check, and the URL contract in lockstep. No `as` casts needed at
2667
// the membership check (the predicate uses `.some` over the typed tuple).
@@ -30,11 +71,35 @@ export type ShowParam = (typeof SHOW_PARAMS)[number]
3071
const isShowParam = (value: unknown): value is ShowParam =>
3172
typeof value === 'string' && SHOW_PARAMS.some((candidate) => candidate === value)
3273

74+
// A `?url=` value is dropped straight into the iframe `src` AND its origin
75+
// becomes the postMessage bridge target, so it must be an absolute http(s) URL
76+
// on the editor's own base-domain family (e.g. any `*.simplepdf.com` tenant).
77+
// Rejecting everything else keeps a crafted `?url=javascript:...`, a relative
78+
// path resolving against our own origin, or a third-party origin (which would
79+
// be framed with our clipboard permissions and wired to the bridge) out of the
80+
// iframe. The leading dot on the suffix check blocks look-alikes like
81+
// `evilsimplepdf.com`.
82+
const isEmbeddableUrl = (value: unknown): value is string => {
83+
if (typeof value !== 'string' || value === '') {
84+
return false
85+
}
86+
try {
87+
const { protocol, host } = new URL(value)
88+
if (protocol !== 'http:' && protocol !== 'https:') {
89+
return false
90+
}
91+
return host === BASE_DOMAIN_URL.host || host.endsWith(`.${BASE_DOMAIN_URL.host}`)
92+
} catch {
93+
return false
94+
}
95+
}
96+
3397
type HomeSearch = {
3498
form: FormId
3599
lang: string
36100
show?: ShowParam
37101
share?: string
102+
url?: string
38103
}
39104

40105
// Server-side: detect the visitor's preferred locale when the URL doesn't
@@ -71,6 +136,7 @@ export const Route = createFileRoute('/')({
71136
lang,
72137
...(isShowParam(raw.show) ? { show: raw.show } : {}),
73138
...(typeof raw.share === 'string' && raw.share !== '' ? { share: raw.share } : {}),
139+
...(isEmbeddableUrl(raw.url) ? { url: raw.url } : {}),
74140
}
75141
},
76142
beforeLoad: async ({ search }) => {
@@ -121,47 +187,6 @@ export const Route = createFileRoute('/')({
121187
},
122188
})
123189

124-
// Trim incoming env strings, treat empty as missing. Outputs `string | undefined`.
125-
const TrimmedOptionalString = z.preprocess((val) => {
126-
if (typeof val !== 'string') {
127-
return undefined
128-
}
129-
const trimmed = val.trim()
130-
return trimmed === '' ? undefined : trimmed
131-
}, z.string().min(1).optional())
132-
133-
// The company identifier is the only required env var. A missing value MUST
134-
// fail loudly at startup instead of silently pointing at production SimplePDF
135-
// with the demo's shared identifier (which would either succeed and bill the
136-
// demo workspace, or fail at iframe-load time with a confusing whitelist
137-
// error). Base domain is optional with a default of https://simplepdf.com;
138-
// override for staging, alternate prod tenants, or a local SimplePDF dev
139-
// checkout.
140-
const ClientEnvSchema = z.object({
141-
VITE_SIMPLEPDF_COMPANY_IDENTIFIER: z.preprocess(
142-
(val) => (typeof val === 'string' ? val.trim() : val),
143-
z.string().min(1, 'VITE_SIMPLEPDF_COMPANY_IDENTIFIER is required (see .env.example)'),
144-
),
145-
VITE_SIMPLEPDF_BASE_DOMAIN: TrimmedOptionalString.pipe(z.url().optional()),
146-
})
147-
148-
const DEFAULT_BASE_DOMAIN = 'https://simplepdf.com'
149-
150-
const clientEnv = ((): z.infer<typeof ClientEnvSchema> => {
151-
const result = ClientEnvSchema.safeParse({
152-
VITE_SIMPLEPDF_COMPANY_IDENTIFIER: import.meta.env.VITE_SIMPLEPDF_COMPANY_IDENTIFIER,
153-
VITE_SIMPLEPDF_BASE_DOMAIN: import.meta.env.VITE_SIMPLEPDF_BASE_DOMAIN,
154-
})
155-
if (!result.success) {
156-
throw new Error(`Client env invalid:\n${z.prettifyError(result.error)}`)
157-
}
158-
return result.data
159-
})()
160-
161-
const COMPANY_IDENTIFIER = clientEnv.VITE_SIMPLEPDF_COMPANY_IDENTIFIER
162-
const BASE_DOMAIN_URL = new URL(clientEnv.VITE_SIMPLEPDF_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN)
163-
const EDITOR_ORIGIN = `${BASE_DOMAIN_URL.protocol}//${COMPANY_IDENTIFIER}.${BASE_DOMAIN_URL.host}`
164-
165190
// Locales the SimplePDF editor can render via i18n path-prefix routing.
166191
// English is the default on the non-prefixed path, so it is not listed here.
167192
const EDITOR_SUPPORTED_LOCALES = new Set(['de', 'es', 'fr', 'it', 'nl', 'pt'])
@@ -183,20 +208,36 @@ const buildEditorSrc = ({ pdfUrl, lang }: { pdfUrl: string; lang: string }): str
183208
}
184209

185210
function Home() {
186-
const { form, lang } = Route.useSearch()
211+
const { form, lang, url } = Route.useSearch()
187212
const { demoGate, welcomeDismissed } = Route.useLoaderData()
188213
const localeForms = getFormsForLocale(lang)
189214
const currentForm = localeForms.forms[form] ?? localeForms.forms[DEFAULT_FORM_ID]
190215
const navigate = useNavigate()
191216
const iframeRef = useRef<HTMLIFrameElement>(null)
192-
const editorResetKey = `${currentForm.id}:${lang}`
217+
// A `?url=` overrides everything: its value becomes the iframe `src` verbatim
218+
// and the bridge targets that URL's origin so the copilot keeps driving the
219+
// editor even when the URL points at a different fork / subdomain. Without
220+
// it, the editor URL is built from the picked demo form + locale (the
221+
// default behaviour). `url` is validated as an absolute http(s) URL in
222+
// validateSearch, so `new URL(url)` never throws here.
223+
const editorTarget = ((): { src: string; origin: string } => {
224+
if (url !== undefined) {
225+
return { src: url, origin: new URL(url).origin }
226+
}
227+
return { src: buildEditorSrc({ pdfUrl: currentForm.pdfUrl, lang }), origin: EDITOR_ORIGIN }
228+
})()
229+
const editorResetKey = url ?? `${currentForm.id}:${lang}`
193230
const { bridge, bridgeState } = useIframeBridge({
194231
iframeRef,
195-
editorOrigin: EDITOR_ORIGIN,
232+
editorOrigin: editorTarget.origin,
196233
resetKey: editorResetKey,
197234
logger: bridgeLogger,
198235
})
199236
const isDocumentLoaded = bridgeState.kind === 'document_loaded'
237+
// A `?url=` always supplies the document, so the user is never asked to
238+
// upload one — only the `custom` demo form (which opens the native file
239+
// picker) requires an upload.
240+
const requiresUserUpload = url === undefined && form === 'custom'
200241

201242
// WORKAROUND: the SimplePDF editor does not currently emit an outbound
202243
// FIELD_ADDED event when the user drops a field via the toolbar, so the
@@ -234,6 +275,7 @@ function Home() {
234275
form: prev.form ?? DEFAULT_FORM_ID,
235276
lang: nextLang,
236277
...(prev.share !== undefined ? { share: prev.share } : {}),
278+
...(prev.url !== undefined ? { url: prev.url } : {}),
237279
}),
238280
})
239281
},
@@ -262,6 +304,7 @@ function Home() {
262304
lang: prev.lang ?? DEFAULT_LANGUAGE_CODE,
263305
show: 'info' as const,
264306
...(prev.share !== undefined ? { share: prev.share } : {}),
307+
...(prev.url !== undefined ? { url: prev.url } : {}),
265308
}),
266309
})
267310
}, [dismissWelcome, navigate])
@@ -271,18 +314,12 @@ function Home() {
271314
<Layout
272315
locale={lang}
273316
currentFormId={form}
274-
editor={
275-
<EditorPane
276-
ref={iframeRef}
277-
iframeKey={editorResetKey}
278-
editorSrc={buildEditorSrc({ pdfUrl: currentForm.pdfUrl, lang })}
279-
/>
280-
}
317+
editor={<EditorPane ref={iframeRef} iframeKey={editorResetKey} editorSrc={editorTarget.src} />}
281318
chat={
282319
<ChatPane
283320
bridge={bridge}
284321
isReady={isDocumentLoaded}
285-
requiresUserUpload={form === 'custom'}
322+
requiresUserUpload={requiresUserUpload}
286323
language={lang}
287324
onLanguageChange={handleLanguageChange}
288325
form={form}

0 commit comments

Comments
 (0)