@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
382 lines (329 loc) • 11.9 kB
text/typescript
import { atom } from '@tldraw/state'
import { fetch } from '@tldraw/utils'
import { publishDates } from '../../version'
import { getDefaultCdnBaseUrl } from '../utils/assets'
import { importPublicKey, str2ab } from '../utils/licensing'
const GRACE_PERIOD_DAYS = 5
export const FLAGS = {
ANNUAL_LICENSE: 0x1,
PERPETUAL_LICENSE: 0x2,
INTERNAL_LICENSE: 0x4,
WITH_WATERMARK: 0x8,
}
const HIGHEST_FLAG = Math.max(...Object.values(FLAGS))
export const PROPERTIES = {
ID: 0,
HOSTS: 1,
FLAGS: 2,
EXPIRY_DATE: 3,
}
const NUMBER_OF_KNOWN_PROPERTIES = Object.keys(PROPERTIES).length
const LICENSE_EMAIL = 'sales@tldraw.com'
const WATERMARK_TRACK_SRC = `${getDefaultCdnBaseUrl()}/watermarks/watermark-track.svg`
/** @internal */
export interface LicenseInfo {
id: string
hosts: string[]
flags: number
expiryDate: string
}
/** @internal */
export type InvalidLicenseReason =
| 'invalid-license-key'
| 'no-key-provided'
| 'has-key-development-mode'
/** @internal */
export type LicenseFromKeyResult = InvalidLicenseKeyResult | ValidLicenseKeyResult
/** @internal */
export interface InvalidLicenseKeyResult {
isLicenseParseable: false
reason: InvalidLicenseReason
}
/** @internal */
export interface ValidLicenseKeyResult {
isLicenseParseable: true
license: LicenseInfo
isDevelopment: boolean
isDomainValid: boolean
expiryDate: Date
isAnnualLicense: boolean
isAnnualLicenseExpired: boolean
isPerpetualLicense: boolean
isPerpetualLicenseExpired: boolean
isInternalLicense: boolean
isLicensedWithWatermark: boolean
}
/** @internal */
export type TestEnvironment = 'development' | 'production'
/** @internal */
export class LicenseManager {
private publicKey =
'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHJh0uUfxHtCGyerXmmatE368Hd9rI6LH9oPDQihnaCryRFWEVeOvf9U/SPbyxX74LFyJs5tYeAHq5Nc0Ax25LQ'
public isDevelopment: boolean
public isTest: boolean
public isCryptoAvailable: boolean
state = atom<'pending' | 'licensed' | 'licensed-with-watermark' | 'unlicensed'>(
'license state',
'pending'
)
public verbose = true
constructor(
licenseKey: string | undefined,
testPublicKey?: string,
testEnvironment?: TestEnvironment
) {
this.isTest = process.env.NODE_ENV === 'test'
this.isDevelopment = this.getIsDevelopment(testEnvironment)
this.publicKey = testPublicKey || this.publicKey
this.isCryptoAvailable = !!crypto.subtle
this.getLicenseFromKey(licenseKey).then((result) => {
const isUnlicensed = isEditorUnlicensed(result)
if (!this.isDevelopment && isUnlicensed) {
fetch(WATERMARK_TRACK_SRC)
}
if (isUnlicensed) {
this.state.set('unlicensed')
} else if ((result as ValidLicenseKeyResult).isLicensedWithWatermark) {
this.state.set('licensed-with-watermark')
} else {
this.state.set('licensed')
}
})
}
private getIsDevelopment(testEnvironment?: TestEnvironment) {
if (testEnvironment === 'development') return true
if (testEnvironment === 'production') return false
// If we are using https on a non-localhost domain we assume it's a production env and a development one otherwise
return (
!['https:', 'vscode-webview:'].includes(window.location.protocol) ||
window.location.hostname === 'localhost'
)
}
private async extractLicenseKey(licenseKey: string): Promise<LicenseInfo> {
const [data, signature] = licenseKey.split('.')
const [prefix, encodedData] = data.split('/')
if (!prefix.startsWith('tldraw-')) {
throw new Error(`Unsupported prefix '${prefix}'`)
}
const publicCryptoKey = await importPublicKey(this.publicKey)
let isVerified
try {
isVerified = await crypto.subtle.verify(
{
name: 'ECDSA',
hash: { name: 'SHA-256' },
},
publicCryptoKey,
new Uint8Array(str2ab(atob(signature))),
new Uint8Array(str2ab(atob(encodedData)))
)
} catch (e) {
console.error(e)
throw new Error('Could not perform signature validation')
}
if (!isVerified) {
throw new Error('Invalid signature')
}
let decodedData: any
try {
decodedData = JSON.parse(atob(encodedData))
} catch {
throw new Error('Could not parse object')
}
if (decodedData.length > NUMBER_OF_KNOWN_PROPERTIES) {
this.outputMessages([
'License key contains some unknown properties.',
'You may want to update tldraw packages to a newer version to get access to new functionality.',
])
}
return {
id: decodedData[PROPERTIES.ID],
hosts: decodedData[PROPERTIES.HOSTS],
flags: decodedData[PROPERTIES.FLAGS],
expiryDate: decodedData[PROPERTIES.EXPIRY_DATE],
}
}
async getLicenseFromKey(licenseKey?: string): Promise<LicenseFromKeyResult> {
if (!licenseKey) {
if (!this.isDevelopment) {
this.outputNoLicenseKeyProvided()
}
return { isLicenseParseable: false, reason: 'no-key-provided' }
}
if (this.isDevelopment && !this.isCryptoAvailable) {
if (this.verbose) {
// eslint-disable-next-line no-console
console.log(
'tldraw: you seem to be in a development environment that does not support crypto. License not verified.'
)
// eslint-disable-next-line no-console
console.log('You should check that this works in production separately.')
}
// We can't parse the license if we are in development mode since crypto
// is not available on http
return { isLicenseParseable: false, reason: 'has-key-development-mode' }
}
// Borrowed idea from AG Grid:
// Copying from various sources (like PDFs) can include zero-width characters.
// This helps makes sure the key validation doesn't fail.
let cleanedLicenseKey = licenseKey.replace(/[\u200B-\u200D\uFEFF]/g, '')
cleanedLicenseKey = cleanedLicenseKey.replace(/\r?\n|\r/g, '')
try {
const licenseInfo = await this.extractLicenseKey(cleanedLicenseKey)
const expiryDate = new Date(licenseInfo.expiryDate)
const isAnnualLicense = this.isFlagEnabled(licenseInfo.flags, FLAGS.ANNUAL_LICENSE)
const isPerpetualLicense = this.isFlagEnabled(licenseInfo.flags, FLAGS.PERPETUAL_LICENSE)
const result: ValidLicenseKeyResult = {
license: licenseInfo,
isLicenseParseable: true,
isDevelopment: this.isDevelopment,
isDomainValid: this.isDomainValid(licenseInfo),
expiryDate,
isAnnualLicense,
isAnnualLicenseExpired: isAnnualLicense && this.isAnnualLicenseExpired(expiryDate),
isPerpetualLicense,
isPerpetualLicenseExpired: isPerpetualLicense && this.isPerpetualLicenseExpired(expiryDate),
isInternalLicense: this.isFlagEnabled(licenseInfo.flags, FLAGS.INTERNAL_LICENSE),
isLicensedWithWatermark: this.isFlagEnabled(licenseInfo.flags, FLAGS.WITH_WATERMARK),
}
this.outputLicenseInfoIfNeeded(result)
return result
} catch (e: any) {
this.outputInvalidLicenseKey(e.message)
// If the license can't be parsed, it's invalid
return { isLicenseParseable: false, reason: 'invalid-license-key' }
}
}
private isDomainValid(licenseInfo: LicenseInfo) {
const currentHostname = window.location.hostname.toLowerCase()
return licenseInfo.hosts.some((host) => {
const normalizedHost = host.toLowerCase().trim()
// Allow the domain if listed and www variations, 'example.com' allows 'example.com' and 'www.example.com'
if (
normalizedHost === currentHostname ||
`www.${normalizedHost}` === currentHostname ||
normalizedHost === `www.${currentHostname}`
) {
return true
}
// If host is '*', we allow all domains.
if (host === '*') {
// All domains allowed.
return true
}
// Glob testing, we only support '*.somedomain.com' right now.
if (host.includes('*')) {
const globToRegex = new RegExp(host.replace(/\*/g, '.*?'))
return globToRegex.test(currentHostname) || globToRegex.test(`www.${currentHostname}`)
}
// VSCode support
if (window.location.protocol === 'vscode-webview:') {
const currentUrl = new URL(window.location.href)
const extensionId = currentUrl.searchParams.get('extensionId')
if (normalizedHost === extensionId) {
return true
}
}
return false
})
}
private getExpirationDateWithoutGracePeriod(expiryDate: Date) {
return new Date(expiryDate.getFullYear(), expiryDate.getMonth(), expiryDate.getDate())
}
private getExpirationDateWithGracePeriod(expiryDate: Date) {
return new Date(
expiryDate.getFullYear(),
expiryDate.getMonth(),
expiryDate.getDate() + GRACE_PERIOD_DAYS + 1 // Add 1 day to include the expiration day
)
}
private isAnnualLicenseExpired(expiryDate: Date) {
const expiration = this.getExpirationDateWithGracePeriod(expiryDate)
const isExpired = new Date() >= expiration
// If it is not expired yet (including the grace period), but after the expiry date we warn the users
if (!isExpired && new Date() >= this.getExpirationDateWithoutGracePeriod(expiryDate)) {
this.outputMessages([
'tldraw license is about to expire, you are in a grace period.',
`Please reach out to ${LICENSE_EMAIL} if you would like to renew your license.`,
])
}
return isExpired
}
private isPerpetualLicenseExpired(expiryDate: Date) {
const expiration = this.getExpirationDateWithGracePeriod(expiryDate)
const dates = {
major: new Date(publishDates.major),
minor: new Date(publishDates.minor),
}
// We allow patch releases, but the major and minor releases should be within the expiration date
return dates.major >= expiration || dates.minor >= expiration
}
private isFlagEnabled(flags: number, flag: number) {
return (flags & flag) === flag
}
private outputNoLicenseKeyProvided() {
// Noop, we don't need to show this message.
// this.outputMessages([
// 'No tldraw license key provided!',
// `Please reach out to ${LICENSE_EMAIL} if you would like to license tldraw or if you'd like a trial.`,
// ])
}
private outputInvalidLicenseKey(msg: string) {
this.outputMessages(['Invalid tldraw license key', `Reason: ${msg}`])
}
private outputLicenseInfoIfNeeded(result: ValidLicenseKeyResult) {
if (result.isAnnualLicenseExpired) {
this.outputMessages([
'Your tldraw license has expired!',
`Please reach out to ${LICENSE_EMAIL} to renew.`,
])
}
if (!result.isDomainValid && !result.isDevelopment) {
this.outputMessages([
'This tldraw license key is not valid for this domain!',
`Please reach out to ${LICENSE_EMAIL} if you would like to use tldraw on other domains.`,
])
}
// If we added a new flag it will be twice the value of the currently highest flag.
// And if all the current flags are on we would get the `HIGHEST_FLAG * 2 - 1`, so anything higher than that means there are new flags.
if (result.license.flags >= HIGHEST_FLAG * 2) {
this.outputMessages([
'This tldraw license contains some unknown flags.',
'You may want to update tldraw packages to a newer version to get access to new functionality.',
])
}
}
private outputMessages(messages: string[]) {
if (this.isTest) return
if (this.verbose) {
this.outputDelimiter()
for (const message of messages) {
// eslint-disable-next-line no-console
console.log(
`%c${message}`,
`color: white; background: crimson; padding: 2px; border-radius: 3px;`
)
}
this.outputDelimiter()
}
}
private outputDelimiter() {
// eslint-disable-next-line no-console
console.log(
'%c-------------------------------------------------------------------',
`color: white; background: crimson; padding: 2px; border-radius: 3px;`
)
}
static className = 'tl-watermark_SEE-LICENSE'
}
export function isEditorUnlicensed(result: LicenseFromKeyResult) {
if (!result.isLicenseParseable) return true
if (!result.isDomainValid && !result.isDevelopment) return true
if (result.isPerpetualLicenseExpired || result.isAnnualLicenseExpired) {
if (result.isInternalLicense) {
throw new Error('License: Internal license expired.')
}
return true
}
return false
}