{
- return
- },
- code: Code,
- a: ({ node, href, children, ...props }) => {
- children = children ? Array.isArray(children) ? children : [children] : []
- // don't allow zoomable images to be wrapped in links
- if (children.some(e => e?.props?.node?.tagName === 'img')) {
- return <>{children}>
- }
-
- // if outlawed, render the link as text
- if (outlawed) {
- return href
- }
-
- // If [text](url) was parsed as and text is not empty and not a link itself,
- // we don't render it as an image since it was probably a conscious choice to include text.
- const text = children[0]
- let url
- try {
- url = !href.startsWith('/') && new URL(href)
- } catch {
- // ignore invalid URLs
- }
-
- const internalURL = process.env.NEXT_PUBLIC_URL
- if (!!text && !/^https?:\/\//.test(text)) {
- if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
- return (
- {text}
-
- )
- }
- if (text.startsWith?.('@')) {
- // user mention might be within a markdown link like this: [@user foo bar](url)
- const name = text.replace('@', '').split(' ')[0]
- return (
-
-
- {text}
-
-
- )
- } else if (href.startsWith('/') || url?.origin === internalURL) {
- try {
- const { linkText } = parseInternalLinks(href)
- if (linkText) {
- return (
-
- {text}
-
- )
- }
- } catch {
- // ignore errors like invalid URLs
- }
-
- return (
-
- {text}
-
- )
- }
- return (
- // eslint-disable-next-line
- {text}
- )
- }
-
- try {
- const { linkText } = parseInternalLinks(href)
- if (linkText) {
- return (
-
- {linkText}
-
- )
- }
- } catch {
- // ignore errors like invalid URLs
- }
-
- const videoWrapperStyles = {
- maxWidth: topLevel ? '640px' : '320px',
- margin: '0.5rem 0',
- paddingRight: '15px'
- }
-
- const { provider, id, meta } = parseEmbedUrl(href)
- // Youtube video embed
- if (provider === 'youtube') {
- return (
-
-
-
- )
- }
-
- // Rumble video embed
- if (provider === 'rumble') {
- return (
-
- )
- }
-
- if (provider === 'peertube') {
- return (
-
- )
- }
-
- // assume the link is an image which will fallback to link if it's not
- return {children}
- },
- img: Img
- }}
- remarkPlugins={[gfm, mention, sub]}
- rehypePlugins={[rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript]}
+ components={components}
+ remarkPlugins={remarkPlugins}
+ rehypePlugins={rehypePlugins}
>
{children}
diff --git a/components/text.module.css b/components/text.module.css
index 431cd99f..4b5d158e 100644
--- a/components/text.module.css
+++ b/components/text.module.css
@@ -128,24 +128,33 @@
margin-bottom: .5rem;
}
-.text img, .text video {
+.text .mediaContainer {
display: block;
margin-top: .5rem;
margin-bottom: .5rem;
- max-width: 100%;
+ width: 100%;
height: auto;
+ overflow: hidden;
max-height: 25vh;
aspect-ratio: var(--aspect-ratio);
}
-.text img {
- cursor: zoom-in;
+.mediaContainer img, .mediaContainer video {
+ display: block;
object-fit: contain;
+ width: auto;
+ max-height: inherit;
+ height: 100%;
+ aspect-ratio: var(--aspect-ratio);
+}
+
+.mediaContainer img {
+ cursor: zoom-in;
min-width: 30%;
object-position: left top;
}
-.text img.topLevel, .text video.topLevel {
+.mediaContainer.topLevel {
margin-top: .75rem;
margin-bottom: .75rem;
max-height: 35vh;
diff --git a/copilot/imgproxy/manifest.yml b/copilot/imgproxy/manifest.yml
index a64b48cb..0853612c 100644
--- a/copilot/imgproxy/manifest.yml
+++ b/copilot/imgproxy/manifest.yml
@@ -16,7 +16,7 @@ http:
# Configuration for your containers and service.
image:
- location: ${PRIVATE_REPO}/imgproxy:v3.21.0-ml-amd64
+ location: docker.imgproxy.pro/imgproxy:v3.25.0-ml-amd64
# Port exposed through your container to route traffic to it.
port: 8080
@@ -49,6 +49,7 @@ variables: # Pass environment variables as key value pairs.
IMGPROXY_WRITE_TIMEOUT: 10
IMGPROXY_DOWNLOAD_TIMEOUT: 9
IMGPROXY_WORKERS: 4
+ IMGPROXY_ENABLE_VIDEO_THUMBNAILS: 1
secrets: # Pass secrets from AWS Systems Manager (SSM) Parameter Store.
IMGPROXY_KEY: /copilot/${COPILOT_APPLICATION_NAME}/${COPILOT_ENVIRONMENT_NAME}/secrets/imgproxy_key
diff --git a/lib/url.js b/lib/url.js
index ac334f0e..d3083d39 100644
--- a/lib/url.js
+++ b/lib/url.js
@@ -168,6 +168,15 @@ export function parseNwcUrl (walletConnectUrl) {
return params
}
+export function decodeProxyUrl (imgproxyUrl) {
+ const parts = imgproxyUrl.split('/')
+ // base64url is not a known encoding in browsers
+ // so we need to replace the invalid chars
+ const b64Url = parts[parts.length - 1].replace(/-/g, '+').replace(/_/, '/')
+ const originalUrl = Buffer.from(b64Url, 'base64').toString('utf-8')
+ return originalUrl
+}
+
// eslint-disable-next-line
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i
diff --git a/worker/imgproxy.js b/worker/imgproxy.js
index 13e8ebed..7ad4df52 100644
--- a/worker/imgproxy.js
+++ b/worker/imgproxy.js
@@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto'
import { extractUrls } from '@/lib/md.js'
import { isJob } from '@/lib/item.js'
import path from 'node:path'
+import { decodeProxyUrl } from '@/lib/url'
const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
(process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY)
@@ -47,13 +48,6 @@ function matchUrl (matchers, url) {
}
}
-function decodeOriginalUrl (imgproxyUrl) {
- const parts = imgproxyUrl.split('/')
- const b64Url = parts[parts.length - 1]
- const originalUrl = Buffer.from(b64Url, 'base64url').toString('utf-8')
- return originalUrl
-}
-
export async function imgproxy ({ data: { id, forceFetch = false }, models }) {
if (!imgProxyEnabled) return
@@ -100,18 +94,17 @@ export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
if (url.startsWith(IMGPROXY_URL)) {
console.log('[imgproxy] id:', id, '-- proxy url, decoding original url:', url)
// backwards compatibility: we used to replace image urls with imgproxy urls
- url = decodeOriginalUrl(url)
+ url = decodeProxyUrl(url)
console.log('[imgproxy] id:', id, '-- original url:', url)
}
- if (!(await isImageURL(fetchUrl, { forceFetch }))) {
+ if (!(await isMediaURL(fetchUrl, { forceFetch }))) {
console.log('[imgproxy] id:', id, '-- not image url:', url)
continue
}
imgproxyUrls[url] = {}
try {
- imgproxyUrls[url] = {
- dimensions: await getDimensions(fetchUrl)
- }
+ imgproxyUrls[url] = await getMetadata(fetchUrl)
+ console.log('[imgproxy] id:', id, '-- dimensions:', imgproxyUrls[url])
} catch (err) {
console.log('[imgproxy] id:', id, '-- error getting dimensions (possibly not running imgproxy pro)', err)
}
@@ -124,12 +117,13 @@ export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
return imgproxyUrls
}
-const getDimensions = async (url) => {
- const options = '/d:1'
+const getMetadata = async (url) => {
+ // video metadata, dimensions, format
+ const options = '/vm:1/d:1/f:1'
const imgproxyUrl = new URL(createImgproxyPath({ url, options, pathname: '/info' }), IMGPROXY_URL).toString()
const res = await fetch(imgproxyUrl)
- const { width, height } = await res.json()
- return { width, height }
+ const { width, height, format, video_streams: videoStreams } = await res.json()
+ return { dimensions: { width, height }, format, video: !!videoStreams?.length }
}
const createImgproxyPath = ({ url, pathname = '/', options }) => {
@@ -152,7 +146,7 @@ async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {})
return response
}
-const isImageURL = async (url, { forceFetch }) => {
+const isMediaURL = async (url, { forceFetch }) => {
if (cache.has(url)) return cache.get(url)
if (!forceFetch && matchUrl(imageUrlMatchers, url)) {
@@ -162,21 +156,21 @@ const isImageURL = async (url, { forceFetch }) => {
return false
}
- let isImage
+ let isMedia
// first run HEAD with small timeout
try {
// https://stackoverflow.com/a/68118683
const res = await fetchWithTimeout(url, { timeout: 1000, method: 'HEAD' })
const buf = await res.blob()
- isImage = buf.type.startsWith('image/')
+ isMedia = buf.type.startsWith('image/') || buf.type.startsWith('video/')
} catch (err) {
console.log(url, err)
}
// For HEAD requests, positives are most likely true positives.
// However, negatives may be false negatives
- if (isImage) {
+ if (isMedia) {
cache.set(url, true)
return true
}
@@ -185,13 +179,13 @@ const isImageURL = async (url, { forceFetch }) => {
try {
const res = await fetchWithTimeout(url, { timeout: 10000 })
const buf = await res.blob()
- isImage = buf.type.startsWith('image/')
+ isMedia = buf.type.startsWith('image/') || buf.type.startsWith('video/')
} catch (err) {
console.log(url, err)
}
- cache.set(url, isImage)
- return isImage
+ cache.set(url, isMedia)
+ return isMedia
}
const hexDecode = (hex) => Buffer.from(hex, 'hex')