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 %s characters.`, CHANNEL_NAME_MIN_LEN ) ); } contentName = path; } const nameBadChars = (channelName || name).match(/[^A-Za-z0-9-]/g); if (nameBadChars) { throw new Error( __( `Invalid character %s in name: %s.`, nameBadChars.length == 1 ? "" : "s", 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 %s.`, 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 %s.`, 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 %s in path: %s`, count == 1 ? "" : "s", 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;