// @flow const isProduction = process.env.NODE_ENV === 'production'; const channelNameMinLength = 1; const claimIdMaxLength = 40; // see https://spec.lbry.com/#urls export const regexInvalidURI = /[ =&#:$@%?;/\\"<>%{}|^~[\]`\u{0000}-\u{0008}\u{000b}-\u{000c}\u{000e}-\u{001F}\u{D800}-\u{DFFF}\u{FFFE}-\u{FFFF}]/u; export const regexAddress = /^(b|r)(?=[^0OIl]{32,33})[0-9A-Za-z]{32,33}$/; const regexPartProtocol = '^((?:lbry://)?)'; const regexPartStreamOrChannelName = '([^:$#/]*)'; const regexPartModifierSeparator = '([:$#]?)([^/]*)'; const queryStringBreaker = '^([\\S]+)([?][\\S]*)'; const separateQuerystring = new RegExp(queryStringBreaker); const MOD_SEQUENCE_SEPARATOR = '*'; const MOD_CLAIM_ID_SEPARATOR_OLD = '#'; const MOD_CLAIM_ID_SEPARATOR = ':'; const MOD_BID_POSITION_SEPARATOR = '$'; /** * Parses a LBRY name into its component parts. Throws errors with user-friendly * messages for invalid names. * * Returns a dictionary with keys: * - path (string) * - isChannel (boolean) * - streamName (string, if present) * - streamClaimId (string, if present) * - channelName (string, if present) * - channelClaimId (string, if present) * - primaryClaimSequence (int, if present) * - secondaryClaimSequence (int, if present) * - primaryBidPosition (int, if present) * - secondaryBidPosition (int, if present) */ export function parseURI(url: string, requireProto: boolean = false): LbryUrlObj { // Break into components. Empty sub-matches are converted to null const componentsRegex = new RegExp( regexPartProtocol + // protocol regexPartStreamOrChannelName + // stream or channel name (stops at the first separator or end) regexPartModifierSeparator + // modifier separator, modifier (stops at the first path separator or end) '(/?)' + // path separator, there should only be one (optional) slash to separate the stream and channel parts regexPartStreamOrChannelName + regexPartModifierSeparator ); // chop off the querystring first let QSStrippedURL, qs; const qsRegexResult = separateQuerystring.exec(url); if (qsRegexResult) { [QSStrippedURL, qs] = qsRegexResult.slice(1).map((match) => match || null); } const cleanURL = QSStrippedURL || url; const regexMatch = componentsRegex.exec(cleanURL) || []; const [proto, ...rest] = regexMatch.slice(1).map((match) => match || null); const path = rest.join(''); const [ streamNameOrChannelName, primaryModSeparator, primaryModValue, pathSep, // eslint-disable-line no-unused-vars possibleStreamName, secondaryModSeparator, secondaryModValue, ] = rest; const searchParams = new URLSearchParams(qs || ''); const startTime = searchParams.get('t'); // Validate protocol if (requireProto && !proto) { throw new Error(__('LBRY URLs must include a protocol prefix (lbry://).')); } // Validate and process name if (!streamNameOrChannelName) { throw new Error(__('URL does not include name.')); } rest.forEach((urlPiece) => { if (urlPiece && urlPiece.includes(' ')) { throw new Error(__('URL can not include a space')); } }); const includesChannel = streamNameOrChannelName.startsWith('@'); const isChannel = streamNameOrChannelName.startsWith('@') && !possibleStreamName; const channelName = includesChannel && streamNameOrChannelName.slice(1); if (includesChannel) { if (!channelName) { throw new Error(__('No channel name after @.')); } if (channelName.length < channelNameMinLength) { throw new Error( __(`Channel names must be at least %channelNameMinLength% characters.`, { channelNameMinLength, }) ); } } // Validate and process modifier const [primaryClaimId, primaryClaimSequence, primaryBidPosition] = parseURIModifier( primaryModSeparator, primaryModValue ); const [secondaryClaimId, secondaryClaimSequence, secondaryBidPosition] = parseURIModifier( secondaryModSeparator, secondaryModValue ); const streamName = includesChannel ? possibleStreamName : streamNameOrChannelName; const streamClaimId = includesChannel ? secondaryClaimId : primaryClaimId; const channelClaimId = includesChannel && primaryClaimId; return { isChannel, path, ...(streamName ? { streamName } : {}), ...(streamClaimId ? { streamClaimId } : {}), ...(channelName ? { channelName } : {}), ...(channelClaimId ? { channelClaimId } : {}), ...(primaryClaimSequence ? { primaryClaimSequence: parseInt(primaryClaimSequence, 10) } : {}), ...(secondaryClaimSequence ? { secondaryClaimSequence: parseInt(secondaryClaimSequence, 10) } : {}), ...(primaryBidPosition ? { primaryBidPosition: parseInt(primaryBidPosition, 10) } : {}), ...(secondaryBidPosition ? { secondaryBidPosition: parseInt(secondaryBidPosition, 10) } : {}), ...(startTime ? { startTime: parseInt(startTime, 10) } : {}), // The values below should not be used for new uses of parseURI // They will not work properly with canonical_urls claimName: streamNameOrChannelName, claimId: primaryClaimId, ...(streamName ? { contentName: streamName } : {}), ...(qs ? { queryString: qs } : {}), }; } function parseURIModifier(modSeperator: ?string, modValue: ?string) { let claimId; let claimSequence; let bidPosition; if (modSeperator) { if (!modValue) { throw new Error(__(`No modifier provided after separator %modSeperator%.`, { modSeperator })); } if (modSeperator === MOD_CLAIM_ID_SEPARATOR || MOD_CLAIM_ID_SEPARATOR_OLD) { claimId = modValue; } else if (modSeperator === MOD_SEQUENCE_SEPARATOR) { claimSequence = modValue; } else if (modSeperator === MOD_BID_POSITION_SEPARATOR) { bidPosition = modValue; } } if (claimId && (claimId.length > claimIdMaxLength || !claimId.match(/^[0-9a-f]+$/))) { throw new Error(__(`Invalid claim ID %claimId%.`, { claimId })); } if (claimSequence && !claimSequence.match(/^-?[1-9][0-9]*$/)) { throw new Error(__('Claim sequence must be a number.')); } if (bidPosition && !bidPosition.match(/^-?[1-9][0-9]*$/)) { throw new Error(__('Bid position must be a number.')); } return [claimId, claimSequence, bidPosition]; } const errorHistory = []; function logErrorOnce(err: string) { if (!errorHistory.includes(err)) { errorHistory.push(err); console.error(err); } } /** * Takes an object in the same format returned by parse() and builds a URI. * * The channelName key will accept names with or without the @ prefix. */ export function buildURI(UrlObj: LbryUrlObj, includeProto: boolean = true, protoDefault: string = 'lbry://'): string { const { streamName, streamClaimId, channelName, channelClaimId, primaryClaimSequence, primaryBidPosition, secondaryClaimSequence, secondaryBidPosition, startTime, ...deprecatedParts } = UrlObj; const { claimId, claimName, contentName } = deprecatedParts; if (!isProduction) { if (claimId) { logErrorOnce("'claimId' should no longer be used. Use 'streamClaimId' or 'channelClaimId' instead"); } if (claimName) { logErrorOnce("'claimName' should no longer be used. Use 'streamClaimName' or 'channelClaimName' instead"); } if (contentName) { logErrorOnce("'contentName' should no longer be used. Use 'streamName' instead"); } } if (!claimName && !channelName && !streamName) { console.error("'claimName', 'channelName', and 'streamName' are all empty. One must be present to build a url."); } const formattedChannelName = channelName && (channelName.startsWith('@') ? channelName : `@${channelName}`); const primaryClaimName = claimName || contentName || formattedChannelName || streamName; const primaryClaimId = claimId || (formattedChannelName ? channelClaimId : streamClaimId); const secondaryClaimName = (!claimName && contentName) || (formattedChannelName ? streamName : null); const secondaryClaimId = secondaryClaimName && streamClaimId; return ( (includeProto ? protoDefault : '') + // primaryClaimName will always exist here because we throw above if there is no "name" value passed in // $FlowFixMe primaryClaimName + (primaryClaimId ? `#${primaryClaimId}` : '') + (primaryClaimSequence ? `:${primaryClaimSequence}` : '') + (primaryBidPosition ? `${primaryBidPosition}` : '') + (secondaryClaimName ? `/${secondaryClaimName}` : '') + (secondaryClaimId ? `#${secondaryClaimId}` : '') + (secondaryClaimSequence ? `:${secondaryClaimSequence}` : '') + (secondaryBidPosition ? `${secondaryBidPosition}` : '') + (startTime ? `?t=${startTime}` : '') ); } /* Takes a parseable LBRY URL and converts it to standard, canonical format */ export function normalizeURI(URL: string) { const { streamName, streamClaimId, channelName, channelClaimId, primaryClaimSequence, primaryBidPosition, secondaryClaimSequence, secondaryBidPosition, startTime, } = parseURI(URL); return buildURI({ streamName, streamClaimId, channelName, channelClaimId, primaryClaimSequence, primaryBidPosition, secondaryClaimSequence, secondaryBidPosition, startTime, }); } export function isURIValid(URL: string, normalize: boolean = true): boolean { try { parseURI(normalize ? normalizeURI(URL) : URL); } catch (error) { return false; } return true; } export function isNameValid(claimName: string) { return !regexInvalidURI.test(claimName); } export function isURIClaimable(URL: string) { let parts; try { parts = parseURI(normalizeURI(URL)); } catch (error) { return false; } return parts && parts.streamName && !parts.streamClaimId && !parts.isChannel; } export function convertToShareLink(URL: string) { const { streamName, streamClaimId, channelName, channelClaimId, primaryBidPosition, primaryClaimSequence, secondaryBidPosition, secondaryClaimSequence, } = parseURI(URL); return buildURI( { streamName, streamClaimId, channelName, channelClaimId, primaryBidPosition, primaryClaimSequence, secondaryBidPosition, secondaryClaimSequence, }, true, 'https://open.lbry.com/' ); } export function splitBySeparator(uri: string) { const protocolLength = 7; return uri.startsWith('lbry://') ? uri.slice(protocolLength).split(/[#:*]/) : uri.split(/#:\*\$/); } export function isURIEqual(uriA: string, uriB: string) { const a = uriA && uriA.replace(/:/g, '#'); const b = uriB && uriB.replace(/:/g, '#'); return a === b; }