const { URL, DOMAIN, SITE_TITLE, SITE_CANONICAL_URL, OG_HOMEPAGE_TITLE, OG_TITLE_SUFFIX, OG_IMAGE_URL, SITE_DESCRIPTION, SITE_NAME, FAVICON, LBRY_WEB_API, } = require('../../config.js'); const { Lbry } = require('lbry-redux'); const { generateEmbedUrl, generateStreamUrl, generateDirectUrl } = require('../../ui/util/web'); const PAGES = require('../../ui/constants/pages'); const { CATEGORY_METADATA } = require('./category-metadata'); const { parseURI, normalizeURI } = require('lbry-redux'); const fs = require('fs'); const path = require('path'); const moment = require('moment'); const removeMd = require('remove-markdown'); const { getJsBundleId } = require('../bundle-id.js'); const jsBundleId = getJsBundleId(); const SDK_API_PATH = `${LBRY_WEB_API}/api/v1`; const PROXY_URL = `${SDK_API_PATH}/proxy`; Lbry.setDaemonConnectionString(PROXY_URL); function insertToHead(fullHtml, htmlToInsert) { const beginStr = ''; const finalStr = ''; const beginIndex = fullHtml.indexOf(beginStr); const finalIndex = fullHtml.indexOf(finalStr); if (beginIndex > -1 && finalIndex > -1 && finalIndex > beginIndex) { return `${fullHtml.slice(0, beginIndex)}${ htmlToInsert || buildOgMetadata() }${fullHtml.slice(finalIndex + finalStr.length)}`; } } function truncateDescription(description, maxChars = 200) { // Get list of single-codepoint strings const chars = [...description]; // Use slice array instead of substring to prevent breaking emojis let truncated = chars.slice(0, maxChars).join(''); // Format truncated string return (chars.length > maxChars) ? truncated + '...' : truncated; } function normalizeClaimUrl(url) { return normalizeURI(url.replace(/:/g, '#')); } function escapeHtmlProperty(property) { return property ? String(property) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') : ''; } function getCategoryMeta(path) { const page = Object.keys(CATEGORY_METADATA).find((x) => path.endsWith(x) || path.endsWith(`${x}/`)); return CATEGORY_METADATA[page]; } // // Normal metadata with option to override certain values // function buildOgMetadata(overrideOptions = {}) { const { title, description, image } = overrideOptions; const cleanDescription = removeMd(description || SITE_DESCRIPTION); const head = `${SITE_TITLE}\n` + `\n` + `\n` + `\n` + `\n` + `\n` + '\n' + `\n` + `\n` + `\n` + `\n` + '\n' + `` + ``; return head; } function conditionallyAddPWA() { let head = ''; if (DOMAIN === 'odysee.com') { head += ''; head += ''; head += ''; } return head; } function addFavicon() { let head = ''; head += ``; return head; } function buildHead() { const head = '' + addFavicon() + conditionallyAddPWA() + buildOgMetadata() + ''; return head; } function buildBasicOgMetadata() { const head = '' + addFavicon() + buildOgMetadata() + ''; return head; } // // Metadata used for urls that need claim information // Also has option to override defaults // function buildClaimOgMetadata(uri, claim, overrideOptions = {}) { // Initial setup for claim based og metadata const { claimName } = parseURI(uri); const { meta, value, signing_channel } = claim; const fee = value && value.fee && (Number(value.fee.amount) || 0); const tags = value && value.tags; const media = value && (value.video || value.audio || value.image); const source = value && value.source; const channel = signing_channel && signing_channel.name; const thumbnail = value && value.thumbnail && value.thumbnail.url; const mediaType = source && source.media_type; const mediaDuration = media && media.duration; const claimTitle = escapeHtmlProperty((value && value.title) || claimName); const releaseTime = (value && value.release_time) || (meta && meta.creation_timestamp) || 0; const claimDescription = value && value.description && value.description.length > 0 ? escapeHtmlProperty(truncateDescription(value.description)) : `View ${claimTitle} on ${SITE_NAME}`; const claimLanguage = value && value.languages && value.languages.length > 0 ? escapeHtmlProperty(value.languages[0]) : 'en_US'; let imageThumbnail; if (fee <= 0 && mediaType && mediaType.startsWith('image/')) { imageThumbnail = generateStreamUrl(claim.name, claim.claim_id); } const claimThumbnail = escapeHtmlProperty(thumbnail) || imageThumbnail || OG_IMAGE_URL || `${URL}/public/v2-og.png`; // Allow for ovverriding default claim based og metadata const title = overrideOptions.title || claimTitle; const description = overrideOptions.description || claimDescription; const cleanDescription = removeMd(description); let head = ''; head += `${addFavicon()}`; head += ''; head += `${title}`; head += ``; if (tags && tags.length > 0) { head += ``; } head += ``; head += ``; head += ``; head += ``; head += ``; head += ``; head += ``; head += ``; // below should be canonical_url, but not provided by chainquery yet head += ``; head += ``; head += ``; if (mediaType && (mediaType.startsWith('video/') || mediaType.startsWith('audio/'))) { const videoUrl = generateEmbedUrl(claim.name, claim.claim_id); head += ``; head += ``; head += ``; if (channel) { head += ``; } head += ``; head += ``; if (releaseTime) { var release = new Date(releaseTime * 1000).toISOString(); head += ``; } if (mediaDuration) { head += ``; } if (media && media.width && media.height) { head += ``; head += ``; head += ``; head += ``; } } else { head += ``; } return head; } function buildGoogleVideoMetadata(uri, claim) { const { claimName } = parseURI(uri); const { meta, value } = claim; const media = value && value.video; const source = value && value.source; const thumbnail = value && value.thumbnail && value.thumbnail.url; const mediaType = source && source.media_type; const mediaDuration = media && media.duration; const claimTitle = escapeHtmlProperty((value && value.title) || claimName); const releaseTime = (value && value.release_time) || (meta && meta.creation_timestamp) || 0; const claimDescription = value && value.description && value.description.length > 0 ? escapeHtmlProperty(truncateDescription(value.description)) : `View ${claimTitle} on ${SITE_NAME}`; if (!mediaType || !mediaType.startsWith('video/')) { return ''; } const claimThumbnail = escapeHtmlProperty(thumbnail) || OG_IMAGE_URL || `${URL}/public/v2-og.png`; // https://developers.google.com/search/docs/data-types/video const googleVideoMetadata = { // --- Must --- '@context': 'https://schema.org', '@type': 'VideoObject', name: `${claimTitle}`, description: `${removeMd(claimDescription)}`, thumbnailUrl: `${claimThumbnail}`, uploadDate: `${new Date(releaseTime * 1000).toISOString()}`, // --- Recommended --- duration: mediaDuration ? moment.duration(mediaDuration * 1000).toISOString() : undefined, contentUrl: generateDirectUrl(claim.name, claim.claim_id), embedUrl: generateEmbedUrl(claim.name, claim.claim_id), }; if ( !googleVideoMetadata.description.replace(/\s/g, '').length || googleVideoMetadata.thumbnailUrl.startsWith('data:image') || !googleVideoMetadata.thumbnailUrl.startsWith('http') ) { return ''; } return ( '\n' ); } async function resolveClaimOrRedirect(ctx, url, ignoreRedirect = false) { let claim; try { const response = await Lbry.resolve({ urls: [url] }); if (response && response[url] && !response[url].error) { claim = response && response[url]; const isRepost = claim.reposted_claim && claim.reposted_claim.name && claim.reposted_claim.claim_id; if (isRepost && !ignoreRedirect) { ctx.redirect(`/${claim.reposted_claim.name}:${claim.reposted_claim.claim_id}`); return; } } } catch {} return claim; } let html; async function getHtml(ctx) { if (!html) { html = fs.readFileSync(path.join(__dirname, '/../dist/index.html'), 'utf8'); } const requestPath = decodeURIComponent(ctx.path); if (requestPath.length === 0) { const ogMetadata = buildBasicOgMetadata(); return insertToHead(html, ogMetadata); } const invitePath = `/$/${PAGES.INVITE}/`; const embedPath = `/$/${PAGES.EMBED}/`; if (requestPath.includes(invitePath)) { try { const inviteChannel = requestPath.slice(invitePath.length); const inviteChannelUrl = normalizeClaimUrl(inviteChannel); const claim = await resolveClaimOrRedirect(ctx, inviteChannelUrl); const invitePageMetadata = buildClaimOgMetadata(inviteChannelUrl, claim, { title: `Join ${claim.name} on ${SITE_NAME}`, description: `Join ${claim.name} on ${SITE_NAME}, a content wonderland owned by everyone (and no one).`, }); return insertToHead(html, invitePageMetadata); } catch (e) { // Something about the invite channel is messed up // Enter generic invite metadata const invitePageMetadata = buildOgMetadata({ title: `Join a friend on ${SITE_NAME}`, description: `Join a friend on ${SITE_NAME}, a content wonderland owned by everyone (and no one).`, }); return insertToHead(html, invitePageMetadata); } } if (requestPath.includes(embedPath)) { const claimUri = normalizeClaimUrl(requestPath.replace(embedPath, '').replace('/', '#')); const claim = await resolveClaimOrRedirect(ctx, claimUri, true); if (claim) { const ogMetadata = buildClaimOgMetadata(claimUri, claim); const googleVideoMetadata = buildGoogleVideoMetadata(claimUri, claim); return insertToHead(html, ogMetadata.concat('\n', googleVideoMetadata)); } return insertToHead(html); } const categoryMeta = getCategoryMeta(requestPath); if (categoryMeta) { const categoryPageMetadata = buildOgMetadata({ title: categoryMeta.title, description: categoryMeta.description, image: categoryMeta.image, }); return insertToHead(html, categoryPageMetadata); } if (!requestPath.includes('$')) { const claimUri = normalizeClaimUrl(requestPath.slice(1)); const claim = await resolveClaimOrRedirect(ctx, claimUri); if (claim) { const ogMetadata = buildClaimOgMetadata(claimUri, claim); const googleVideoMetadata = buildGoogleVideoMetadata(claimUri, claim); return insertToHead(html, ogMetadata.concat('\n', googleVideoMetadata)); } } const ogMetadataAndPWA = buildHead(); return insertToHead(html, ogMetadataAndPWA); } module.exports = { insertToHead, buildHead, getHtml };