Browse Source

fixed discussions rendering

imwald
Silberengel 4 months ago
parent
commit
7c638af322
  1. 183
      PROXY_SETUP.md
  2. 81
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 78
      src/components/Note/MarkdownArticle/preprocessMediaLinks.ts
  4. 5
      src/services/web.service.ts

183
PROXY_SETUP.md

@ -157,15 +157,23 @@ sudo systemctl restart apache2
ServerAlias www.jumble.imwald.eu ServerAlias www.jumble.imwald.eu
# Reverse Proxy Configuration # Reverse Proxy Configuration
ProxyPreserveHost On
# Proxy for the jumble-proxy-server (must come BEFORE the catch-all / rule) # Proxy for the jumble-proxy-server (must come BEFORE the catch-all / rule)
# The code constructs: ${proxyServer}/sites/${encodeURIComponent(url)} # The code constructs: ${proxyServer}/sites/${encodeURIComponent(url)}
# So /proxy/sites/... needs to be forwarded to http://127.0.0.1:8090/sites/... # So /proxy/sites/... needs to be forwarded to http://127.0.0.1:8090/sites/...
ProxyPass /proxy/ http://127.0.0.1:8090/ # IMPORTANT: Use Location block to scope headers properly for /proxy/ path only
ProxyPassReverse /proxy/ http://127.0.0.1:8090/ <Location /proxy/>
ProxyPreserveHost Off
ProxyPass http://127.0.0.1:8090/
ProxyPassReverse http://127.0.0.1:8090/
# Unset forwarded headers that might make the proxy server use Host header instead of URL path
RequestHeader unset X-Forwarded-Host
RequestHeader unset X-Forwarded-Server
RequestHeader set Host "127.0.0.1:8090"
</Location>
# Reverse Proxy for the main Jumble app # Reverse Proxy for the main Jumble app (needs Host header preserved)
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:32768/ ProxyPass / http://127.0.0.1:32768/
ProxyPassReverse / http://127.0.0.1:32768/ ProxyPassReverse / http://127.0.0.1:32768/
@ -196,10 +204,32 @@ sudo systemctl reload apache2
5. **Test the proxy route:** 5. **Test the proxy route:**
```bash ```bash
# Test with a real URL - the code constructs /proxy/sites/{encoded-url} # Test with a real URL - the code constructs /proxy/sites/{encoded-url}
curl -I https://jumble.imwald.eu/proxy/sites/https%3A%2F%2Fexample.com curl https://jumble.imwald.eu/proxy/sites/https%3A%2F%2Fexample.com
# Should return 200 OK if working correctly # Should return example.com's HTML, NOT jumble.imwald.eu's HTML
# If you see Jumble HTML, the proxy server is using the Host header instead of the URL path
```
**If the test returns Jumble HTML instead of the requested site's HTML:**
The proxy server is using the `Host` header (`jumble.imwald.eu`) to determine what to fetch. Update your Apache config to use `ProxyPreserveHost Off` for the `/proxy/` path:
```apache
# In your Apache config, change from:
ProxyPreserveHost On
ProxyPass /proxy/ http://127.0.0.1:8090/
# To:
ProxyPreserveHost Off
ProxyPass /proxy/ http://127.0.0.1:8090/
ProxyPassReverse /proxy/ http://127.0.0.1:8090/
# Then set it back to On for the main app:
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:32768/
``` ```
Then reload Apache and test again.
6. **Build with the proxy URL:** 6. **Build with the proxy URL:**
```bash ```bash
docker build \ docker build \
@ -220,6 +250,147 @@ docker build \
## Troubleshooting ## Troubleshooting
### If Proxy Returns Jumble HTML Instead of Requested Site
If you've set `ProxyPreserveHost Off` but still get Jumble HTML, test the proxy server directly:
**1. Test the proxy server directly (bypassing Apache):**
```bash
# Test direct connection to proxy on port 8090
curl http://127.0.0.1:8090/sites/https%3A%2F%2Fexample.com
# Should return example.com's HTML, NOT jumble.imwald.eu's HTML
```
**2. Check proxy server logs:**
```bash
docker logs imwald-jumble-proxy --tail 50
# Look for what URL the proxy is trying to fetch
# This will show if the proxy server is receiving the correct path or if it's using Host header
```
**2b. Check what request the proxy server is actually receiving:**
```bash
# Enable verbose logging in Apache to see what it's forwarding
# Or check what the proxy receives by making a test request and watching logs:
docker logs -f imwald-jumble-proxy &
# Then in another terminal:
curl -v https://jumble.imwald.eu/proxy/sites/https%3A%2F%2Fexample.com
# Look at the proxy logs to see what URL it tried to fetch
```
**3. Check if Apache is receiving `/proxy/` requests:**
```bash
# Watch Apache access log to see if /proxy/ requests reach Apache
sudo tail -f /var/log/apache2/access.log | grep proxy
# Then in another terminal, make a request:
curl https://jumble.imwald.eu/proxy/sites/https%3A%2F%2Fexample.com
# Check if the request appears in Apache logs
```
**3b. Check what Apache is forwarding:**
```bash
# Check Apache error log for proxy-related entries
sudo tail -f /var/log/apache2/error.log
# Then make a request and see what Apache is doing
```
**3c. Verify Apache config is being used:**
```bash
# Check that your Location block syntax is correct:
sudo apache2ctl -S | grep jumble.imwald.eu
# Make sure the site is enabled and config syntax is correct:
sudo apache2ctl configtest
```
**4. Possible Issues:**
**IMPORTANT: If you see `Server: nginx` in response headers, nginx is in front of Apache!**
If you see `Server: nginx/1.29.3` or `X-Powered-By: PleskLin` in the response headers, nginx reverse proxy is handling requests before Apache. You need to configure nginx directly (bypassing Plesk interface) to pass `/proxy/` to Apache, or route it directly to the proxy server.
**If nginx is in front of Apache (bypassing Plesk interface):**
Since nginx is handling requests before Apache, configure nginx directly by editing nginx config files.
**Option A: Configure nginx to pass `/proxy/` through to Apache:**
1. Find nginx config for your domain:
```bash
# Plesk usually stores configs here:
/etc/nginx/conf.d/vhost.conf
# or
/etc/nginx/conf.d/jumble.imwald.eu.conf
# or check Plesk's nginx vhosts:
/etc/nginx/plesk.conf.d/vhosts/jumble.imwald.eu.conf
```
2. Edit the nginx config file for `jumble.imwald.eu`
3. Add this location block BEFORE any catch-all location:
```nginx
location /proxy/ {
# Forward to Apache (check what port Apache is listening on)
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
**Note:** Replace `8080` with the port Apache is actually listening on. Check with:
```bash
sudo netstat -tlnp | grep apache
# or
sudo ss -tlnp | grep apache
```
4. Test nginx config: `sudo nginx -t`
5. Reload nginx: `sudo systemctl reload nginx`
**Then Apache will handle it with your existing Apache config.**
**Option B: Configure nginx to route `/proxy/` directly to the proxy server (simpler):**
1. Edit nginx config for `jumble.imwald.eu` (same location as above)
2. Add this location block BEFORE any catch-all location:
```nginx
location /proxy/ {
proxy_pass http://127.0.0.1:8090/;
proxy_set_header Host 127.0.0.1:8090;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Don't send X-Forwarded-Host which might confuse the proxy
proxy_set_header X-Forwarded-Host "";
}
```
3. Test nginx config: `sudo nginx -t`
4. Reload nginx: `sudo systemctl reload nginx`
This bypasses Apache entirely for `/proxy/` requests and goes directly to the proxy server.
**Other possible issues:**
- The proxy server might be using the `X-Forwarded-Host` header instead of the URL path
- The proxy server might need a specific Host header value
- The proxy server might not be correctly parsing `/sites/...` path
**5. Unset forwarded headers that might confuse the proxy server:**
```apache
# The proxy server might be using X-Forwarded-Host instead of the URL path
# Unset or modify these headers for the /proxy/ path:
ProxyPass /proxy/ http://127.0.0.1:8090/
ProxyPassReverse /proxy/ http://127.0.0.1:8090/
ProxyPreserveHost Off
# Unset forwarded headers that might interfere
RequestHeader unset X-Forwarded-Host
RequestHeader unset X-Forwarded-Server
RequestHeader set Host "127.0.0.1:8090"
```
### Other Console Errors
If you see errors in the console: If you see errors in the console:
- `[WebService] No proxy server configured` - `VITE_PROXY_SERVER` is undefined or empty - `[WebService] No proxy server configured` - `VITE_PROXY_SERVER` is undefined or empty
- `[WebService] CORS/Network error` - The proxy URL might be wrong, or CORS isn't configured - `[WebService] CORS/Network error` - The proxy URL might be wrong, or CORS isn't configured

81
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -6,7 +6,7 @@ import WebPreview from '@/components/WebPreview'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList, toProfile } from '@/lib/link' import { toNote, toNoteList, toProfile } from '@/lib/link'
import { useMediaExtraction } from '@/hooks' import { useMediaExtraction } from '@/hooks'
import { cleanUrl, isImage, isMedia } from '@/lib/url' import { cleanUrl, isImage, isMedia, isVideo, isAudio } from '@/lib/url'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
@ -22,6 +22,7 @@ import NostrNode from './NostrNode'
import { remarkNostr } from './remarkNostr' import { remarkNostr } from './remarkNostr'
import { remarkHashtags } from './remarkHashtags' import { remarkHashtags } from './remarkHashtags'
import { remarkUnwrapImages } from './remarkUnwrapImages' import { remarkUnwrapImages } from './remarkUnwrapImages'
import { preprocessMediaLinks } from './preprocessMediaLinks'
import { Components } from './types' import { Components } from './types'
export default function MarkdownArticle({ export default function MarkdownArticle({
@ -40,6 +41,11 @@ export default function MarkdownArticle({
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null) const contentRef = useRef<HTMLDivElement>(null)
// Preprocess content to convert plain media URLs to markdown syntax
const processedContent = useMemo(() => {
return preprocessMediaLinks(event.content)
}, [event.content])
// Use unified media extraction service // Use unified media extraction service
const extractedMedia = useMediaExtraction(event, event.content) const extractedMedia = useMediaExtraction(event, event.content)
@ -55,6 +61,17 @@ export default function MarkdownArticle({
return hashtags return hashtags
}, [event.content]) }, [event.content])
// Extract media URLs that are in the content (so we don't render them twice)
const mediaUrlsInContent = useMemo(() => {
const urls = new Set<string>()
const mediaUrlRegex = /(https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|heic|mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v|mp3|wav|flac|aac|m4a|opus|wma)(\?[^\s<>"']*)?)/gi
let match
while ((match = mediaUrlRegex.exec(event.content)) !== null) {
urls.add(cleanUrl(match[0]))
}
return urls
}, [event.content])
// All images from useMediaExtraction are already cleaned and deduplicated // All images from useMediaExtraction are already cleaned and deduplicated
// This includes images from content, tags, imeta, r tags, etc. // This includes images from content, tags, imeta, r tags, etc.
const allImages = extractedMedia.images const allImages = extractedMedia.images
@ -205,9 +222,22 @@ export default function MarkdownArticle({
child => React.isValidElement(child) && child.type === Image child => React.isValidElement(child) && child.type === Image
) )
// If link contains an image, let the image handle the click for lightbox // If link contains only an image, render just the image without the link wrapper
// Just wrap it in an anchor that won't interfere with image clicks // This prevents the image from opening as a file - clicking opens lightbox instead
if (hasImage) { if (hasImage) {
// Check if this is just an image with no other content
const childrenArray = React.Children.toArray(children)
const onlyImage = childrenArray.length === 1 &&
React.isValidElement(childrenArray[0]) &&
childrenArray[0].type === Image
if (onlyImage) {
// Just render the image directly, no link wrapper
return <>{children}</>
}
// If there's text along with the image, keep the link wrapper
// but prevent navigation when clicking the image itself
return ( return (
<a <a
{...props} {...props}
@ -239,13 +269,9 @@ export default function MarkdownArticle({
const isRegularUrl = href.startsWith('http://') || href.startsWith('https://') const isRegularUrl = href.startsWith('http://') || href.startsWith('https://')
const shouldShowPreview = isRegularUrl && !isImage(cleanedHref) && !isMedia(cleanedHref) const shouldShowPreview = isRegularUrl && !isImage(cleanedHref) && !isMedia(cleanedHref)
// For regular URLs, wrap in a component that shows WebPreview // For regular URLs, show WebPreview directly (no wrapper)
if (shouldShowPreview) { if (shouldShowPreview) {
return ( return <WebPreview url={cleanedHref} className="mt-2" />
<span className="inline-block mt-2">
<WebPreview url={cleanedHref} className="w-full max-w-md" />
</span>
)
} }
return ( return (
@ -381,8 +407,20 @@ export default function MarkdownArticle({
img: ({ src }) => { img: ({ src }) => {
if (!src) return null if (!src) return null
// Find the index of this image in allImages (includes content and tags, already deduplicated)
const cleanedSrc = cleanUrl(src) const cleanedSrc = cleanUrl(src)
// Check if this is actually a video or audio URL (converted by remarkMedia)
if (cleanedSrc && (isVideo(cleanedSrc) || isAudio(cleanedSrc))) {
return (
<MediaPlayer
src={cleanedSrc}
className="max-w-[400px] my-2"
mustLoad={false}
/>
)
}
// Find the index of this image in allImages (includes content and tags, already deduplicated)
const imageIndex = cleanedSrc const imageIndex = cleanedSrc
? allImages.findIndex(img => cleanUrl(img.url) === cleanedSrc) ? allImages.findIndex(img => cleanUrl(img.url) === cleanedSrc)
: -1 : -1
@ -394,7 +432,7 @@ export default function MarkdownArticle({
image={{ url: src, pubkey: event.pubkey }} image={{ url: src, pubkey: event.pubkey }}
className="max-w-[400px] rounded-lg my-2 cursor-zoom-in" className="max-w-[400px] rounded-lg my-2 cursor-zoom-in"
classNames={{ classNames={{
wrapper: 'rounded-lg', wrapper: 'rounded-lg inline-block',
errorPlaceholder: 'aspect-square h-[30vh]' errorPlaceholder: 'aspect-square h-[30vh]'
}} }}
data-markdown-image="true" data-markdown-image="true"
@ -565,6 +603,16 @@ export default function MarkdownArticle({
color: #86efac !important; /* Tailwind green-300 */ color: #86efac !important; /* Tailwind green-300 */
text-decoration: underline !important; text-decoration: underline !important;
} }
/* Make images display inline-block so they can wrap horizontally */
.prose span[data-markdown-image] {
display: inline-block !important;
margin: 0.5rem !important;
}
/* When images are in paragraphs, make those paragraphs inline or flex */
.prose p:has(span[data-markdown-image]:only-child) {
display: inline-block;
width: 100%;
}
`}</style> `}</style>
<div <div
ref={contentRef} ref={contentRef}
@ -608,20 +656,21 @@ export default function MarkdownArticle({
) )
})()} })()}
<Markdown remarkPlugins={[remarkGfm, remarkMath, remarkUnwrapImages, remarkNostr, remarkHashtags]} components={components}> <Markdown remarkPlugins={[remarkGfm, remarkMath, remarkUnwrapImages, remarkNostr, remarkHashtags]} components={components}>
{event.content} {processedContent}
</Markdown> </Markdown>
{/* Inline Media - Show for non-article content (kinds 1, 11, 1111) */} {/* Inline Media - Show for non-article content (kinds 1, 11, 1111) */}
{!showImageGallery && extractedMedia.videos.length > 0 && ( {/* Only render media that's not already in the content (from tags, imeta, etc.) */}
{!showImageGallery && extractedMedia.videos.filter(v => !mediaUrlsInContent.has(v.url)).length > 0 && (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
{extractedMedia.videos.map((video) => ( {extractedMedia.videos.filter(v => !mediaUrlsInContent.has(v.url)).map((video) => (
<MediaPlayer key={video.url} src={video.url} mustLoad={true} className="w-full" /> <MediaPlayer key={video.url} src={video.url} mustLoad={true} className="w-full" />
))} ))}
</div> </div>
)} )}
{!showImageGallery && extractedMedia.audio.length > 0 && ( {!showImageGallery && extractedMedia.audio.filter(a => !mediaUrlsInContent.has(a.url)).length > 0 && (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
{extractedMedia.audio.map((audio) => ( {extractedMedia.audio.filter(a => !mediaUrlsInContent.has(a.url)).map((audio) => (
<MediaPlayer key={audio.url} src={audio.url} mustLoad={true} className="w-full" /> <MediaPlayer key={audio.url} src={audio.url} mustLoad={true} className="w-full" />
))} ))}
</div> </div>

78
src/components/Note/MarkdownArticle/preprocessMediaLinks.ts

@ -0,0 +1,78 @@
import { isImage, isVideo, isAudio } from '@/lib/url'
/**
* Preprocess markdown content to convert plain media URLs to proper markdown syntax
* - Images: `https://example.com/image.png` -> `![](https://example.com/image.png)`
* - Videos: `https://example.com/video.mp4` -> `![](https://example.com/video.mp4)`
* - Audio: `https://example.com/audio.mp3` -> `![](https://example.com/audio.mp3)`
*/
export function preprocessMediaLinks(content: string): string {
let processed = content
// Find all matches but process them manually to avoid complex regex lookbehind
const allMatches: Array<{ url: string; index: number }> = []
let match
// Find all candidate URLs
const tempRegex = /https?:\/\/[^\s<>"']+/gi
while ((match = tempRegex.exec(content)) !== null) {
const index = match.index
const url = match[0]
const before = content.substring(Math.max(0, index - 10), index)
// Check if this URL is already part of markdown syntax
// Skip if preceded by: [text](url, ![text](url, or ](url
if (before.match(/\[[^\]]*$/) || before.match(/\]\([^)]*$/) || before.match(/!\[[^\]]*$/)) {
continue
}
allMatches.push({ url, index })
}
// Process in reverse order to preserve indices
for (let i = allMatches.length - 1; i >= 0; i--) {
const { url, index } = allMatches[i]
// Check if URL is in code block
const beforeUrl = content.substring(0, index)
const backticksCount = (beforeUrl.match(/```/g) || []).length
if (backticksCount % 2 === 1) {
continue // In code block
}
// Check if URL is in inline code
const lastBacktick = beforeUrl.lastIndexOf('`')
if (lastBacktick !== -1) {
const afterUrl = content.substring(index + url.length)
const nextBacktick = afterUrl.indexOf('`')
if (nextBacktick !== -1) {
const codeBefore = beforeUrl.substring(lastBacktick + 1)
const codeAfter = afterUrl.substring(0, nextBacktick)
// If no newlines between backticks, it's inline code
if (!codeBefore.includes('\n') && !codeAfter.includes('\n')) {
continue
}
}
}
// Check if it's a media URL
const isImageUrl = isImage(url)
const isVideoUrl = isVideo(url)
const isAudioUrl = isAudio(url)
let replacement: string
if (isImageUrl || isVideoUrl || isAudioUrl) {
// Media URLs: convert to ![](url)
replacement = `![](${url})`
} else {
// Don't convert non-media URLs - let autolink handle them
continue
}
// Replace the URL
processed = processed.substring(0, index) + replacement + processed.substring(index + url.length)
}
return processed
}

5
src/services/web.service.ts

@ -56,6 +56,11 @@ class WebService {
} }
const html = await res.text() const html = await res.text()
// Debug: Log a snippet of the HTML to see what we're getting
const htmlSnippet = html.substring(0, 500)
console.log(`[WebService] Received HTML snippet for ${url} (via ${fetchUrl}):`, htmlSnippet)
const parser = new DOMParser() const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html') const doc = parser.parseFromString(html, 'text/html')

Loading…
Cancel
Save