
Reworks how the readonly urls work. Till now we just used a simple function that would scramble the slugs. Now we use a proper key value mapping between regular and readonly slugs: - We use two KV stores. One is for going from a slug to a readonly slug and the other one for going the other way around. They are populated at the same time. - We separate preview KV stores (dev, preview, staging) from production one. I've already created these on Cloudflare. [My understanding is ](https://developers.cloudflare.com/kv/reference/data-security/#encryption-at-rest)that ids [can be public](https://community.cloudflare.com/t/is-it-safe-to-keep-kv-ids-in-a-public-git-repo/517387/4) since we can only access KV from our worker. Happy to move them to env variables though. - [x] Disable creating new rooms when tldraw is embedded inside iframes on other websites (we check the referrer and if it's not the same as the iframe's origin we don't allow it) - [x] Fork a project when inside an iframe now opens the forked project on tldraw.com and not inside iframe. - [x] We allow embeding of iframes, but we now track the where they are used via the referrer. We send this to Vercel analytics. - [x] Improved UX of the share menu to make it less confusing. Toggle is gone. - [x] `/new` and `/r` routes not redirect to `/`. - [x] This introduces a new `/ro` route for readonly rooms. Legacy rooms still live on `/v`. - [x] Brought back `dotcom-shared` project to share code between BE and FE. Mostly types. - [x] Prevent creating of rooms by entering `/r/non-existing-slug`. - [x] Handle getting a readonly slug for old rooms. Added a comment about it [here](https://github.com/tldraw/tldraw/pull/3192/files#diff-c0954b3dc71bb7097c39656441175f3238ed60cf5cee64077c06e21da82182cbR17-R18). - [x] We no longer expose editor on the window object for readonly rooms. Prevents the users disabling readonly rooms manually. ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [ ] `sdk` — Changes the tldraw SDK - [x] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff <!-- ❗ Please select a 'Type' label ❗️ --> - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Make sure old readonly rooms still work. 2. Creating a readonly link from an existing room should still use `/v` path. 3. Newly created rooms should use `/ro` path for readonly rooms. Make sure these work as well. 4. `/r` room was disabled and redirects to `/` 5. `/new` should still work when not inside iframes. - [x] Unit Tests - [ ] End to end tests ### Release Notes 1. This adds new functionality for readonly rooms: - We have a new route `/ro` for newly created readonly rooms. These rooms no longer use the scrambling logic to create readonly slugs. Instead we now use KV storage from cloudflare to track the mapping for slugs -> readonly slug and readonly slug -> slug. - The old route `/v` is preserved, so that the old room still work as they did before. - For old rooms we will keep on generating the old readonly slugs, but for new rooms we'll start using the new logic. 2. We no longer prevent embedding of tldraw inside iframes. 3. We do prevent generating new rooms from inside the iframes though. `/r`, `/new`, `/r/non-existing-id` should not allow creation of new rooms inside iframes. Only `/new` still works when not inside iframes. 4. Forking a project from inside an iframe now opens it on tldraw.com 5. Slight copy change on the sharing menu. We no longer have a toggle between readonly and non-readonly links. 6. `editor` and `app` are no longer exposed on the window object for readonly rooms. Prevents users from using the `updateInstanceState` to escape readonly rooms. --------- Co-authored-by: Mime Čuvalo <mimecuvalo@gmail.com>
252 lines
7.3 KiB
TypeScript
252 lines
7.3 KiB
TypeScript
import {
|
|
CreateRoomRequestBody,
|
|
CreateSnapshotRequestBody,
|
|
CreateSnapshotResponseBody,
|
|
Snapshot,
|
|
} from '@tldraw/dotcom-shared'
|
|
import { useMemo } from 'react'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import {
|
|
AssetRecordType,
|
|
Editor,
|
|
TLAsset,
|
|
TLAssetId,
|
|
TLRecord,
|
|
TLShape,
|
|
TLShapeId,
|
|
TLUiEventHandler,
|
|
TLUiOverrides,
|
|
TLUiToastsContextType,
|
|
TLUiTranslationKey,
|
|
isShape,
|
|
} from 'tldraw'
|
|
import { useMultiplayerAssets } from '../hooks/useMultiplayerAssets'
|
|
import { getViewportUrlQuery } from '../hooks/useUrlState'
|
|
import { cloneAssetForShare } from './cloneAssetForShare'
|
|
import { ASSET_UPLOADER_URL } from './config'
|
|
import { getParentOrigin, isInIframe } from './iFrame'
|
|
import { shouldLeaveSharedProject } from './shouldLeaveSharedProject'
|
|
import { trackAnalyticsEvent } from './trackAnalyticsEvent'
|
|
import { UI_OVERRIDE_TODO_EVENT, useHandleUiEvents } from './useHandleUiEvent'
|
|
|
|
export const SHARE_PROJECT_ACTION = 'share-project' as const
|
|
export const SHARE_SNAPSHOT_ACTION = 'share-snapshot' as const
|
|
export const LEAVE_SHARED_PROJECT_ACTION = 'leave-shared-project' as const
|
|
export const FORK_PROJECT_ACTION = 'fork-project' as const
|
|
|
|
const CREATE_SNAPSHOT_ENDPOINT = `/api/snapshots`
|
|
const SNAPSHOT_UPLOAD_URL = `/api/new-room`
|
|
|
|
async function getSnapshotLink(
|
|
source: string,
|
|
editor: Editor,
|
|
handleUiEvent: TLUiEventHandler,
|
|
addToast: TLUiToastsContextType['addToast'],
|
|
msg: (id: TLUiTranslationKey) => string,
|
|
uploadFileToAsset: (file: File) => Promise<TLAsset>,
|
|
parentSlug: string | undefined
|
|
) {
|
|
handleUiEvent('share-snapshot' as UI_OVERRIDE_TODO_EVENT, { source } as UI_OVERRIDE_TODO_EVENT)
|
|
const data = await getRoomData(editor, addToast, msg, uploadFileToAsset)
|
|
if (!data) return ''
|
|
|
|
const res = await fetch(CREATE_SNAPSHOT_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
snapshot: data,
|
|
schema: editor.store.schema.serialize(),
|
|
parent_slug: parentSlug,
|
|
} satisfies CreateSnapshotRequestBody),
|
|
})
|
|
const response = (await res.json()) as CreateSnapshotResponseBody
|
|
|
|
if (!res.ok || response.error) {
|
|
console.error(await res.text())
|
|
return ''
|
|
}
|
|
const paramsToUse = getViewportUrlQuery(editor)
|
|
const params = paramsToUse ? `?${new URLSearchParams(paramsToUse).toString()}` : ''
|
|
return new Blob([`${window.location.origin}/s/${response.roomId}${params}`], {
|
|
type: 'text/plain',
|
|
})
|
|
}
|
|
|
|
export async function getNewRoomResponse(snapshot: Snapshot) {
|
|
return await fetch(SNAPSHOT_UPLOAD_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
origin: getParentOrigin(),
|
|
snapshot,
|
|
} satisfies CreateRoomRequestBody),
|
|
})
|
|
}
|
|
|
|
export function useSharing(): TLUiOverrides {
|
|
const navigate = useNavigate()
|
|
const id = useSearchParams()[0].get('id') ?? undefined
|
|
const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL)
|
|
const handleUiEvent = useHandleUiEvents()
|
|
const runningInIFrame = isInIframe()
|
|
|
|
return useMemo(
|
|
(): TLUiOverrides => ({
|
|
actions(editor, actions, { addToast, msg, addDialog }) {
|
|
actions[LEAVE_SHARED_PROJECT_ACTION] = {
|
|
id: LEAVE_SHARED_PROJECT_ACTION,
|
|
label: 'action.leave-shared-project',
|
|
readonlyOk: true,
|
|
onSelect: async () => {
|
|
const shouldLeave = await shouldLeaveSharedProject(addDialog)
|
|
if (!shouldLeave) return
|
|
|
|
handleUiEvent('leave-shared-project', {})
|
|
|
|
navigate('/')
|
|
},
|
|
}
|
|
actions[SHARE_PROJECT_ACTION] = {
|
|
id: SHARE_PROJECT_ACTION,
|
|
label: 'action.share-project',
|
|
readonlyOk: true,
|
|
onSelect: async (source) => {
|
|
try {
|
|
handleUiEvent('share-project', { source })
|
|
const data = await getRoomData(editor, addToast, msg, uploadFileToAsset)
|
|
if (!data) return
|
|
|
|
const res = await getNewRoomResponse({
|
|
schema: editor.store.schema.serialize(),
|
|
snapshot: data,
|
|
})
|
|
const response = (await res.json()) as { error: boolean; slug?: string }
|
|
if (!res.ok || response.error) {
|
|
console.error(await res.text())
|
|
throw new Error('Failed to upload snapshot')
|
|
}
|
|
|
|
const query = getViewportUrlQuery(editor)
|
|
const origin = window.location.origin
|
|
const pathname = `/r/${response.slug}?${new URLSearchParams(query ?? {}).toString()}`
|
|
if (runningInIFrame) {
|
|
window.open(`${origin}${pathname}`)
|
|
} else {
|
|
navigate(pathname)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
addToast({
|
|
title: 'Error',
|
|
description: msg('share-menu.upload-failed'),
|
|
severity: 'error',
|
|
})
|
|
}
|
|
},
|
|
}
|
|
actions[SHARE_SNAPSHOT_ACTION] = {
|
|
id: SHARE_SNAPSHOT_ACTION,
|
|
label: 'share-menu.create-snapshot-link',
|
|
readonlyOk: true,
|
|
onSelect: async (source) => {
|
|
const result = getSnapshotLink(
|
|
source,
|
|
editor,
|
|
handleUiEvent,
|
|
addToast,
|
|
msg,
|
|
uploadFileToAsset,
|
|
id
|
|
)
|
|
if (navigator?.clipboard?.write) {
|
|
await navigator.clipboard.write([
|
|
new ClipboardItem({
|
|
'text/plain': result,
|
|
}),
|
|
])
|
|
} else if (navigator?.clipboard?.writeText) {
|
|
const link = await result
|
|
if (link === '') return
|
|
navigator.clipboard.writeText(await link.text())
|
|
}
|
|
},
|
|
}
|
|
actions[FORK_PROJECT_ACTION] = {
|
|
...actions[SHARE_PROJECT_ACTION],
|
|
id: FORK_PROJECT_ACTION,
|
|
label: runningInIFrame ? 'action.fork-project-on-tldraw' : 'action.fork-project',
|
|
}
|
|
return actions
|
|
},
|
|
}),
|
|
[handleUiEvent, navigate, uploadFileToAsset, id, runningInIFrame]
|
|
)
|
|
}
|
|
|
|
async function getRoomData(
|
|
editor: Editor,
|
|
addToast: TLUiToastsContextType['addToast'],
|
|
msg: (id: TLUiTranslationKey) => string,
|
|
uploadFileToAsset: (file: File) => Promise<TLAsset>
|
|
) {
|
|
const rawData = editor.store.serialize()
|
|
|
|
// rawData contains a cache of previously added assets,
|
|
// which we don't want included in the shared document.
|
|
// So let's strip it out.
|
|
|
|
// our final object that holds the data that we'll persist to a stash
|
|
const data: Record<string, TLRecord> = {}
|
|
|
|
// let's get all the assets/shapes in data
|
|
const shapes = new Map<TLShapeId, TLShape>()
|
|
const assets = new Map<TLAssetId, TLAsset>()
|
|
|
|
for (const record of Object.values(rawData)) {
|
|
if (AssetRecordType.isInstance(record)) {
|
|
// collect assets separately, don't add them to the proper doc yet
|
|
assets.set(record.id, record)
|
|
continue
|
|
}
|
|
data[record.id] = record
|
|
if (isShape(record)) {
|
|
shapes.set(record.id, record)
|
|
}
|
|
}
|
|
|
|
// now add only those assets that are referenced in shapes
|
|
for (const shape of shapes.values()) {
|
|
if ('assetId' in shape.props) {
|
|
const asset = assets.get(shape.props.assetId as TLAssetId)
|
|
// if we can't find the asset it either means
|
|
// somethings gone wrong or we've already
|
|
// processed it
|
|
if (!asset) continue
|
|
|
|
data[asset.id] = await cloneAssetForShare(asset, uploadFileToAsset)
|
|
// remove the asset after processing so we don't clone it multiple times
|
|
assets.delete(asset.id)
|
|
}
|
|
}
|
|
|
|
const size = new Blob([JSON.stringify(data)]).size
|
|
|
|
if (size > 3999999) {
|
|
addToast({
|
|
title: 'Too big!',
|
|
description: msg('share-menu.project-too-large'),
|
|
severity: 'warning',
|
|
})
|
|
|
|
trackAnalyticsEvent('shared-fail-too-big', {
|
|
size: size.toString(),
|
|
})
|
|
|
|
return null
|
|
}
|
|
return data
|
|
}
|