const CHANNEL_NAME_MIN_LEN = 4; const CLAIM_ID_MAX_LEN = 40; const lbryuri = {}; /** * Parses a LBRY name into its component parts. Throws errors with user-friendly * messages for invalid names. * * N.B. that "name" indicates the value in the name position of the URI. For * claims for channel content, this will actually be the channel name, and * the content name is in the path (e.g. lbry://@channel/content) * * In most situations, you'll want to use the contentName and channelName keys * and ignore the name key. * * Returns a dictionary with keys: * - name (string): The value in the "name" position in the URI. Note that this * could be either content name or channel name; see above. * - path (string, if persent) * - claimSequence (int, if present) * - bidPosition (int, if present) * - claimId (string, if present) * - isChannel (boolean) * - contentName (string): For anon claims, the name; for channel claims, the path * - channelName (string, if present): Channel name without @ */ lbryuri.parse = function(uri, requireProto=false) { // Break into components. Empty sub-matches are converted to null const componentsRegex = new RegExp( '^((?:lbry:\/\/)?)' + // protocol '([^:$#/]*)' + // name (stops at the first separator or end) '([:$#]?)([^/]*)' + // modifier separator, modifier (stops at the first path separator or end) '(/?)(.*)' // path separator, path ); const [proto, name, modSep, modVal, pathSep, path] = componentsRegex.exec(uri).slice(1).map(match => match || null); let contentName; // Validate protocol if (requireProto && !proto) { throw new Error('LBRY URIs must include a protocol prefix (lbry://).'); } // Validate and process name if (!name) { throw new Error('URI does not include name.'); } const isChannel = name.startsWith('@'); const channelName = isChannel ? name.slice(1) : name; if (isChannel) { if (!channelName) { throw new Error('No channel name after @.'); } if (channelName.length < CHANNEL_NAME_MIN_LEN) { throw new Error(`Channel names must be at least ${CHANNEL_NAME_MIN_LEN} characters.`); } contentName = path; } const nameBadChars = (channelName || name).match(/[^A-Za-z0-9-]/g); if (nameBadChars) { throw new Error(`Invalid character${nameBadChars.length == 1 ? '' : 's'} in name: ${nameBadChars.join(', ')}.`); } // Validate and process modifier (claim ID, bid position or claim sequence) let claimId, claimSequence, bidPosition; if (modSep) { if (!modVal) { throw new Error(`No modifier provided after separator ${modSep}.`); } if (modSep == '#') { claimId = modVal; } else if (modSep == ':') { claimSequence = modVal; } else if (modSep == '$') { bidPosition = modVal; } } if (claimId && (claimId.length > CLAIM_ID_MAX_LEN || !claimId.match(/^[0-9a-f]+$/))) { throw new Error(`Invalid claim ID ${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.'); } // Validate and process path if (path) { if (!isChannel) { throw new Error('Only channel URIs may have a path.'); } const pathBadChars = path.match(/[^A-Za-z0-9-]/g); if (pathBadChars) { throw new Error(`Invalid character${count == 1 ? '' : 's'} in path: ${nameBadChars.join(', ')}`); } contentName = path; } else if (pathSep) { throw new Error('No path provided after /'); } return { name, path, isChannel, ... contentName ? {contentName} : {}, ... channelName ? {channelName} : {}, ... claimSequence ? {claimSequence: parseInt(claimSequence)} : {}, ... bidPosition ? {bidPosition: parseInt(bidPosition)} : {}, ... claimId ? {claimId} : {}, ... path ? {path} : {}, }; } /** * Takes an object in the same format returned by lbryuri.parse() and builds a URI. * * The channelName key will accept names with or without the @ prefix. */ lbryuri.build = function(uriObj, includeProto=true, allowExtraProps=false) { let {name, claimId, claimSequence, bidPosition, path, contentName, channelName} = uriObj; if (channelName) { const channelNameFormatted = channelName.startsWith('@') ? channelName : '@' + channelName; if (!name) { name = channelNameFormatted; } else if (name !== channelNameFormatted) { throw new Error('Received a channel content URI, but name and channelName do not match. "name" represents the value in the name position of the URI (lbry://name...), which for channel content will be the channel name. In most cases, to construct a channel URI you should just pass channelName and contentName.'); } } if (contentName) { if (!name) { name = contentName; } else if (!path) { path = contentName; } if (path && path !== contentName) { throw new Error('path and contentName do not match. Only one is required; most likely you wanted contentName.'); } } return (includeProto ? 'lbry://' : '') + name + (claimId ? `#${claimId}` : '') + (claimSequence ? `:${claimSequence}` : '') + (bidPosition ? `\$${bidPosition}` : '') + (path ? `/${path}` : ''); } /* Takes a parseable LBRY URI and converts it to standard, canonical format (currently this just * consists of adding the lbry:// prefix if needed) */ lbryuri.normalize= function(uri) { const {name, path, bidPosition, claimSequence, claimId} = lbryuri.parse(uri); return lbryuri.build({name, path, claimSequence, bidPosition, claimId}); } lbryuri.isValid = function(uri) { let parts try { parts = lbryuri.parse(lbryuri.normalize(uri)) } catch (error) { return false; } return parts && parts.name; } lbryuri.isValidName = function(name, checkCase=true) { const regexp = new RegExp('^[a-z0-9-]+$', checkCase ? '' : 'i'); return regexp.test(name); } lbryuri.isClaimable = function(uri) { let parts try { parts = lbryuri.parse(lbryuri.normalize(uri)) } catch (error) { return false; } return parts && parts.name && !parts.claimId && !parts.bidPosition && !parts.claimSequence && !parts.isChannel && !parts.path; } window.lbryuri = lbryuri; export default lbryuri;