diff --git a/ui/hocs/withResolvedClaimRender/view.jsx b/ui/hocs/withResolvedClaimRender/view.jsx index 2a6ff2cde7..f51db8d18a 100644 --- a/ui/hocs/withResolvedClaimRender/view.jsx +++ b/ui/hocs/withResolvedClaimRender/view.jsx @@ -1,5 +1,6 @@ // @flow import React from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; import Spinner from 'component/spinner'; import Button from 'component/button'; @@ -34,11 +35,46 @@ type Props = { }; /** - * HigherOrderComponent to resolve a claim and return the correct states for render, such as loading, failed, restricted, etc - * - * @param Component: FunctionalComponentParam - * @returns {FunctionalComponent} + * Checks if a web URL path might be missing the @ prefix for the channel name. + * Returns a corrected path with @ if applicable, or null if not applicable. + * This handles cases like "/channel:id/content:id" which should be "/@channel:id/content:id" + * (e.g., malformed URLs from Grok/Twitter that omit the @) */ +function getCorrectedChannelPath(pathname: string): ?string { + try { + // Remove leading slash + const webPath = pathname.startsWith('/') ? pathname.slice(1) : pathname; + + // Must have a slash with content after it (channel/content pattern) + const slashIndex = webPath.indexOf('/'); + if (slashIndex === -1 || slashIndex === webPath.length - 1) { + return null; + } + + const firstPart = webPath.substring(0, slashIndex); + const secondPart = webPath.substring(slashIndex + 1); + + // First part should not already start with @ + if (firstPart.startsWith('@')) { + return null; + } + + // Both parts should have content (non-empty name before any modifiers) + // Modifiers in web URLs use : instead of # + const firstNameMatch = firstPart.match(/^[^#:$*]+/); + const secondNameMatch = secondPart.match(/^[^#:$*]+/); + + if (!firstNameMatch || !firstNameMatch[0] || !secondNameMatch || !secondNameMatch[0]) { + return null; + } + + // Return the corrected path with @ prefix + return `/@${webPath}`; + } catch (e) { + return null; + } +} + const withResolvedClaimRender = (ClaimRenderComponent: FunctionalComponentParam) => { const ResolvedClaimRender = (props: Props) => { const { @@ -66,8 +102,19 @@ const withResolvedClaimRender = (ClaimRenderComponent: FunctionalComponentParam) ...otherProps } = props; + const { replace: historyReplace } = useHistory(); + const location = useLocation(); const { streamName, /* channelName, */ isChannel } = parseURI(uri); + // Check if the browser URL is missing @ prefix and redirect if needed + // This handles malformed URLs from Grok/Twitter like "/channel:id/content:id" + const correctedPath = getCorrectedChannelPath(location.pathname); + React.useEffect(() => { + if (correctedPath) { + historyReplace(correctedPath + location.search + location.hash); + } + }, [correctedPath, historyReplace, location.search, location.hash]); + const claimIsRestricted = !claimIsMine && (geoRestriction !== null || isClaimBlackListed || isClaimFiltered); const resolveRequired = @@ -111,6 +158,11 @@ const withResolvedClaimRender = (ClaimRenderComponent: FunctionalComponentParam) } }, [resolveRequired, resolveClaim]); + // If URL needs correction, show loading while redirect happens + if (correctedPath) { + return ; + } + if (!hasClaim) { if (hasClaim === undefined) { return ; diff --git a/web/src/html.js b/web/src/html.js index ef0a64f59b..54ba0805bc 100644 --- a/web/src/html.js +++ b/web/src/html.js @@ -21,7 +21,7 @@ const { const { fetchStreamUrl } = require('./fetchStreamUrl'); const { lbryProxy: Lbry } = require('../lbry'); const { getHomepageJsonV1 } = require('./getHomepageJSON'); -const { buildURI, parseURI, normalizeClaimUrl } = require('./lbryURI'); +const { buildURI, parseURI, normalizeClaimUrl, getCorrectedChannelWebPath } = require('./lbryURI'); const fs = require('fs'); const PAGES = require('../../ui/constants/pages'); const path = require('path'); @@ -663,9 +663,20 @@ async function getHtml(ctx) { if (!requestPath.includes('$')) { let parsedUri, claimUri; + let pathToUse = requestPath.slice(1); + + // Check if URL is missing @ prefix (e.g., malformed URLs from Grok/Twitter) + const correctedPath = getCorrectedChannelWebPath(pathToUse); + if (correctedPath) { + // Redirect to the corrected URL with @ prefix, preserving query string + // Use encodeURI to handle special characters like parentheses + const queryString = ctx.querystring ? `?${ctx.querystring}` : ''; + ctx.redirect(encodeURI(`/${correctedPath}`) + queryString); + return; + } try { - parsedUri = parseURI(normalizeClaimUrl(requestPath.slice(1))); + parsedUri = parseURI(normalizeClaimUrl(pathToUse)); claimUri = buildURI({ ...parsedUri, startTime: undefined }, true); } catch (err) { ctx.status = 404; diff --git a/web/src/lbryURI.js b/web/src/lbryURI.js index e0400f483e..aed680141c 100644 --- a/web/src/lbryURI.js +++ b/web/src/lbryURI.js @@ -359,6 +359,95 @@ function normalizeClaimUrl(url) { return normalizeURI(url.replace(/:/g, '#')); } +/** + * Checks if a URI might be missing the @ prefix for the channel name. + * Returns a corrected URI with @ if applicable, or null if not applicable. + * This handles cases like "channel:id/content:id" which should be "@channel:id/content:id" + * (e.g., malformed URLs from Grok/Twitter that omit the @) + */ +function getCorrectedChannelUri(uri) { + try { + // Check if the URI has a path separator (indicating channel/content pattern) + // but doesn't have a channel name (meaning no @ was detected) + const parsed = parseURI(uri); + + // If it already has a channelName, the @ is present - no correction needed + if (parsed.channelName) { + return null; + } + + // Check if the raw URI contains a "/" followed by more content (channel/content pattern) + // Remove lbry:// protocol for checking + const uriWithoutProtocol = uri.replace(/^lbry:\/\//, ''); + const slashIndex = uriWithoutProtocol.indexOf('/'); + + // Must have a slash with content after it (channel/content pattern) + if (slashIndex === -1 || slashIndex === uriWithoutProtocol.length - 1) { + return null; + } + + const firstPart = uriWithoutProtocol.substring(0, slashIndex); + const secondPart = uriWithoutProtocol.substring(slashIndex + 1); + + // First part should not already start with @ + if (firstPart.startsWith('@')) { + return null; + } + + // Both parts should have content (non-empty after any modifiers are stripped) + const firstNameMatch = firstPart.match(/^[^#:$*]+/); + const secondNameMatch = secondPart.match(/^[^#:$*]+/); + + if (!firstNameMatch || !firstNameMatch[0] || !secondNameMatch || !secondNameMatch[0]) { + return null; + } + + // Build the corrected URI with @ prefix + return `lbry://@${uriWithoutProtocol}`; + } catch (e) { + return null; + } +} + +/** + * Checks if a web URL path might be missing the @ prefix for the channel name. + * Returns a corrected path with @ if applicable, or null if not applicable. + * This works on raw web paths BEFORE they are parsed into lbry:// URIs. + * + * Example: "JustMe:05/content:0" -> "@JustMe:05/content:0" + */ +function getCorrectedChannelWebPath(webPath) { + try { + // Must have a slash with content after it (channel/content pattern) + const slashIndex = webPath.indexOf('/'); + if (slashIndex === -1 || slashIndex === webPath.length - 1) { + return null; + } + + const firstPart = webPath.substring(0, slashIndex); + const secondPart = webPath.substring(slashIndex + 1); + + // First part should not already start with @ + if (firstPart.startsWith('@')) { + return null; + } + + // Both parts should have content (non-empty name before any modifiers) + // Modifiers in web URLs use : instead of # + const firstNameMatch = firstPart.match(/^[^#:$*]+/); + const secondNameMatch = secondPart.match(/^[^#:$*]+/); + + if (!firstNameMatch || !firstNameMatch[0] || !secondNameMatch || !secondNameMatch[0]) { + return null; + } + + // Return the corrected path with @ prefix + return `@${webPath}`; + } catch (e) { + return null; + } +} + module.exports = { parseURI, buildURI, @@ -370,4 +459,6 @@ module.exports = { isURIClaimable, splitBySeparator, convertToShareLink, + getCorrectedChannelUri, + getCorrectedChannelWebPath, }; diff --git a/web/src/oEmbed.js b/web/src/oEmbed.js index 4ac42ee005..10ef61e023 100644 --- a/web/src/oEmbed.js +++ b/web/src/oEmbed.js @@ -9,6 +9,7 @@ const { escapeHtmlProperty, } = require('../../ui/util/web'); const { lbryProxy: Lbry } = require('../lbry'); +const { getCorrectedChannelWebPath } = require('./lbryURI'); Lbry.setDaemonConnectionString(PROXY_URL); @@ -17,7 +18,14 @@ Lbry.setDaemonConnectionString(PROXY_URL); // **************************************************************************** async function getClaim(requestUrl) { - const uri = requestUrl.replace(`${URL}/`, 'lbry://'); + // Extract the path from the URL and check for missing @ prefix + let webPath = requestUrl.replace(`${URL}/`, ''); + const correctedPath = getCorrectedChannelWebPath(webPath); + if (correctedPath) { + webPath = correctedPath; + } + + const uri = `lbry://${webPath}`; let claim, error; try { diff --git a/web/src/routes.js b/web/src/routes.js index 2d5731257b..367f5c8d25 100644 --- a/web/src/routes.js +++ b/web/src/routes.js @@ -125,7 +125,10 @@ router.get('*', async (ctx, next) => { } const html = await getHtml(ctx); - ctx.body = html; + // Only set body if not already redirecting (3xx status) + if (ctx.status < 300 || ctx.status >= 400) { + ctx.body = html; + } }); module.exports = router;