Browse Source

bug-fixes

master
Silberengel 4 weeks ago
parent
commit
452303e44e
  1. 2
      Dockerfile
  2. 2
      README.md
  3. 23
      docker-compose.yml
  4. 9
      docker-rebuild.sh
  5. 0
      public/aither.png
  6. 0
      public/apple-touch-icon-114x114.png
  7. 0
      public/apple-touch-icon-120x120.png
  8. 0
      public/apple-touch-icon-144x144.png
  9. 0
      public/apple-touch-icon-152x152.png
  10. 0
      public/apple-touch-icon-180x180.png
  11. 0
      public/apple-touch-icon-57x57.png
  12. 0
      public/apple-touch-icon-60x60.png
  13. 0
      public/apple-touch-icon-72x72.png
  14. 0
      public/apple-touch-icon-76x76.png
  15. 0
      public/favicon.ico
  16. 4
      public/healthz.json
  17. 0
      public/icons/arrow-left.svg
  18. 0
      public/icons/bookmark.svg
  19. 0
      public/icons/check.svg
  20. 0
      public/icons/chevron-down.svg
  21. 0
      public/icons/chevron-up.svg
  22. 0
      public/icons/code.svg
  23. 0
      public/icons/copy.svg
  24. 0
      public/icons/database.svg
  25. 0
      public/icons/download.svg
  26. 0
      public/icons/edit.svg
  27. 0
      public/icons/eye.svg
  28. 0
      public/icons/file-text.svg
  29. 0
      public/icons/heart.svg
  30. 0
      public/icons/highlight.svg
  31. 0
      public/icons/image.svg
  32. 0
      public/icons/key.svg
  33. 0
      public/icons/link.svg
  34. 0
      public/icons/log-in.svg
  35. 0
      public/icons/log-out.svg
  36. 0
      public/icons/message-square.svg
  37. 0
      public/icons/moon.svg
  38. 0
      public/icons/plus.svg
  39. 0
      public/icons/radio.svg
  40. 0
      public/icons/search.svg
  41. 0
      public/icons/send.svg
  42. 0
      public/icons/settings.svg
  43. 0
      public/icons/share.svg
  44. 0
      public/icons/smile.svg
  45. 0
      public/icons/sun.svg
  46. 0
      public/icons/trash.svg
  47. 0
      public/icons/upload.svg
  48. 0
      public/icons/user.svg
  49. 0
      public/icons/video.svg
  50. 0
      public/icons/volume.svg
  51. 0
      public/icons/x.svg
  52. 0
      public/icons/zap.svg
  53. 0
      public/og-image.png
  54. 11
      src/lib/components/content/FileExplorer.svelte
  55. 20
      src/lib/components/content/MarkdownRenderer.svelte
  56. 75
      src/lib/modules/discussions/DiscussionCard.svelte
  57. 3
      src/lib/modules/feed/FeedPost.svelte
  58. 49
      src/lib/services/content/git-repo-fetcher.ts
  59. 148
      src/routes/api/gitea-proxy/[...path]/+server.ts
  60. 11
      svelte.config.js
  61. 10
      vite.config.ts

2
Dockerfile

@ -8,6 +8,8 @@ COPY . .
# If ARG is not provided, ENV will be empty and config.ts will use defaults # If ARG is not provided, ENV will be empty and config.ts will use defaults
ARG VITE_DEFAULT_RELAYS ARG VITE_DEFAULT_RELAYS
ARG VITE_THREAD_TIMEOUT_DAYS ARG VITE_THREAD_TIMEOUT_DAYS
ARG BUILD_DATE
# BUILD_DATE is used to force rebuilds - can be set to current timestamp
ENV VITE_DEFAULT_RELAYS=${VITE_DEFAULT_RELAYS} ENV VITE_DEFAULT_RELAYS=${VITE_DEFAULT_RELAYS}
ENV VITE_THREAD_TIMEOUT_DAYS=${VITE_THREAD_TIMEOUT_DAYS} ENV VITE_THREAD_TIMEOUT_DAYS=${VITE_THREAD_TIMEOUT_DAYS}
RUN npm run build RUN npm run build

2
README.md

@ -2,7 +2,7 @@
A decentralized messageboard built on the Nostr protocol. A decentralized messageboard built on the Nostr protocol.
![Aitherboard logo](/static/og-image.png) ![Aitherboard logo](/og-image.png)
**About**: [https://aitherboard.imwald.eu/about](https://aitherboard.imwald.eu/about) **About**: [https://aitherboard.imwald.eu/about](https://aitherboard.imwald.eu/about)

23
docker-compose.yml

@ -1,16 +1,19 @@
services: services:
aitherboard: aitherboard:
container_name: aitherboard container_name: aitherboard
# Using pre-built image (recommended for production) # Build from source (always rebuilds - use --no-cache flag when running)
image: silberengel/aitherboard:latest build:
# Alternative: Build from source (for development) context: .
# build: dockerfile: Dockerfile
# context: . # Optional: override defaults from config.ts if needed
# # Optional: override defaults from config.ts if needed # Uncomment and modify if you want custom values:
# # Uncomment and modify if you want custom values: # args:
# # args: # VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land"
# # VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land" # VITE_THREAD_TIMEOUT_DAYS: "30"
# # VITE_THREAD_TIMEOUT_DAYS: "30" # Note: To force rebuild: docker-compose build --no-cache aitherboard && docker-compose up -d
# Alternative: Using pre-built image (for production)
# Uncomment the image line below and comment out the build section above
# image: silberengel/aitherboard:latest
ports: ports:
- "9876:9876" - "9876:9876"
environment: environment:

9
docker-rebuild.sh

@ -0,0 +1,9 @@
#!/bin/bash
# Script to rebuild aitherboard container without cache
# Usage: ./docker-rebuild.sh
echo "Rebuilding aitherboard container (no cache)..."
docker-compose build --no-cache aitherboard
echo "Starting containers..."
docker-compose up -d
echo "Done! aitherboard has been rebuilt and restarted."

0
static/aither.png → public/aither.png

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 284 KiB

0
static/apple-touch-icon-114x114.png → public/apple-touch-icon-114x114.png

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

0
static/apple-touch-icon-120x120.png → public/apple-touch-icon-120x120.png

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

0
static/apple-touch-icon-144x144.png → public/apple-touch-icon-144x144.png

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

0
static/apple-touch-icon-152x152.png → public/apple-touch-icon-152x152.png

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

0
static/apple-touch-icon-180x180.png → public/apple-touch-icon-180x180.png

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

0
static/apple-touch-icon-57x57.png → public/apple-touch-icon-57x57.png

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

0
static/apple-touch-icon-60x60.png → public/apple-touch-icon-60x60.png

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

0
static/apple-touch-icon-72x72.png → public/apple-touch-icon-72x72.png

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

0
static/apple-touch-icon-76x76.png → public/apple-touch-icon-76x76.png

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

0
static/favicon.ico → public/favicon.ico

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.3.1", "version": "0.3.1",
"buildTime": "2026-02-13T04:20:13.671Z", "buildTime": "2026-02-14T07:05:05.090Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770956413671 "timestamp": 1771052705090
} }

0
static/icons/arrow-left.svg → public/icons/arrow-left.svg

Before

Width:  |  Height:  |  Size: 234 B

After

Width:  |  Height:  |  Size: 234 B

0
static/icons/bookmark.svg → public/icons/bookmark.svg

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 251 B

0
static/icons/check.svg → public/icons/check.svg

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 223 B

0
static/icons/chevron-down.svg → public/icons/chevron-down.svg

Before

Width:  |  Height:  |  Size: 212 B

After

Width:  |  Height:  |  Size: 212 B

0
static/icons/chevron-up.svg → public/icons/chevron-up.svg

Before

Width:  |  Height:  |  Size: 214 B

After

Width:  |  Height:  |  Size: 214 B

0
static/icons/code.svg → public/icons/code.svg

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 259 B

0
static/icons/copy.svg → public/icons/copy.svg

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 310 B

0
static/icons/database.svg → public/icons/database.svg

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 319 B

0
static/icons/download.svg → public/icons/download.svg

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 B

0
static/icons/edit.svg → public/icons/edit.svg

Before

Width:  |  Height:  |  Size: 325 B

After

Width:  |  Height:  |  Size: 325 B

0
static/icons/eye.svg → public/icons/eye.svg

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 275 B

0
static/icons/file-text.svg → public/icons/file-text.svg

Before

Width:  |  Height:  |  Size: 352 B

After

Width:  |  Height:  |  Size: 352 B

0
static/icons/heart.svg → public/icons/heart.svg

Before

Width:  |  Height:  |  Size: 338 B

After

Width:  |  Height:  |  Size: 338 B

0
static/icons/highlight.svg → public/icons/highlight.svg

Before

Width:  |  Height:  |  Size: 308 B

After

Width:  |  Height:  |  Size: 308 B

0
static/icons/image.svg → public/icons/image.svg

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 326 B

0
static/icons/key.svg → public/icons/key.svg

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 306 B

0
static/icons/link.svg → public/icons/link.svg

Before

Width:  |  Height:  |  Size: 331 B

After

Width:  |  Height:  |  Size: 331 B

0
static/icons/log-in.svg → public/icons/log-in.svg

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 B

0
static/icons/log-out.svg → public/icons/log-out.svg

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

0
static/icons/message-square.svg → public/icons/message-square.svg

Before

Width:  |  Height:  |  Size: 261 B

After

Width:  |  Height:  |  Size: 261 B

0
static/icons/moon.svg → public/icons/moon.svg

Before

Width:  |  Height:  |  Size: 234 B

After

Width:  |  Height:  |  Size: 234 B

0
static/icons/plus.svg → public/icons/plus.svg

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 228 B

0
static/icons/radio.svg → public/icons/radio.svg

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 393 B

0
static/icons/search.svg → public/icons/search.svg

Before

Width:  |  Height:  |  Size: 247 B

After

Width:  |  Height:  |  Size: 247 B

0
static/icons/send.svg → public/icons/send.svg

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 242 B

0
static/icons/settings.svg → public/icons/settings.svg

Before

Width:  |  Height:  |  Size: 965 B

After

Width:  |  Height:  |  Size: 965 B

0
static/icons/share.svg → public/icons/share.svg

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

0
static/icons/smile.svg → public/icons/smile.svg

Before

Width:  |  Height:  |  Size: 333 B

After

Width:  |  Height:  |  Size: 333 B

0
static/icons/sun.svg → public/icons/sun.svg

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 429 B

0
static/icons/trash.svg → public/icons/trash.svg

Before

Width:  |  Height:  |  Size: 302 B

After

Width:  |  Height:  |  Size: 302 B

0
static/icons/upload.svg → public/icons/upload.svg

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 313 B

0
static/icons/user.svg → public/icons/user.svg

Before

Width:  |  Height:  |  Size: 271 B

After

Width:  |  Height:  |  Size: 271 B

0
static/icons/video.svg → public/icons/video.svg

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 322 B

0
static/icons/volume.svg → public/icons/volume.svg

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 312 B

0
static/icons/x.svg → public/icons/x.svg

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 232 B

0
static/icons/zap.svg → public/icons/zap.svg

Before

Width:  |  Height:  |  Size: 355 B

After

Width:  |  Height:  |  Size: 355 B

0
static/og-image.png → public/og-image.png

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 627 KiB

11
src/lib/components/content/FileExplorer.svelte

@ -105,7 +105,8 @@
const apiBase = baseHost.includes('gitlab.com') const apiBase = baseHost.includes('gitlab.com')
? 'https://gitlab.com/api/v4' ? 'https://gitlab.com/api/v4'
: `${baseHost}/api/v4`; : `${baseHost}/api/v4`;
apiUrl = `${apiBase}/projects/${projectPath}/repository/files/${encodeURIComponent(file.path)}/raw?ref=${repoInfo.defaultBranch}`; // Use proxy endpoint to avoid CORS issues
apiUrl = `/api/gitea-proxy/projects/${projectPath}/repository/files/${encodeURIComponent(file.path)}/raw?baseUrl=${encodeURIComponent(apiBase)}&ref=${repoInfo.defaultBranch}`;
} }
} else if (url.includes('onedev')) { } else if (url.includes('onedev')) {
// OneDev API: similar to Gitea, uses /api/v1/repos/{owner}/{repo}/contents/{path} // OneDev API: similar to Gitea, uses /api/v1/repos/{owner}/{repo}/contents/{path}
@ -113,7 +114,9 @@
if (match) { if (match) {
const [, baseUrl, owner, repo] = match; const [, baseUrl, owner, repo] = match;
const cleanRepo = repo.replace(/\.git$/, ''); const cleanRepo = repo.replace(/\.git$/, '');
apiUrl = `${baseUrl}/api/v1/repos/${owner}/${cleanRepo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`; const baseApiUrl = `${baseUrl}/api/v1`;
// Use proxy endpoint to avoid CORS issues
apiUrl = `/api/gitea-proxy/repos/${owner}/${cleanRepo}/contents/${file.path}?baseUrl=${encodeURIComponent(baseApiUrl)}&ref=${repoInfo.defaultBranch}`;
} }
} else { } else {
// Try Gitea/Forgejo pattern (handles .git suffix) - generic fallback // Try Gitea/Forgejo pattern (handles .git suffix) - generic fallback
@ -121,7 +124,9 @@
if (match) { if (match) {
const [, baseUrl, owner, repo] = match; const [, baseUrl, owner, repo] = match;
const cleanRepo = repo.replace(/\.git$/, ''); const cleanRepo = repo.replace(/\.git$/, '');
apiUrl = `${baseUrl}/api/v1/repos/${owner}/${cleanRepo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`; const baseApiUrl = `${baseUrl}/api/v1`;
// Use proxy endpoint to avoid CORS issues
apiUrl = `/api/gitea-proxy/repos/${owner}/${cleanRepo}/contents/${file.path}?baseUrl=${encodeURIComponent(baseApiUrl)}&ref=${repoInfo.defaultBranch}`;
} }
} }

20
src/lib/components/content/MarkdownRenderer.svelte

@ -520,6 +520,7 @@
} }
// 3. Match plain media URLs (using the SAME pattern as getMediaAttachmentUrls) // 3. Match plain media URLs (using the SAME pattern as getMediaAttachmentUrls)
// IMPORTANT: This must match the exact same regex pattern used in FeedPost.mediaAttachmentUrls
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi; const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi;
urlRegex.lastIndex = 0; urlRegex.lastIndex = 0;
while ((match = urlRegex.exec(text)) !== null) { while ((match = urlRegex.exec(text)) !== null) {
@ -540,15 +541,16 @@
} }
const normalizedUrl = normalizeUrl(url); const normalizedUrl = normalizeUrl(url);
// Check exclusion - this is critical for preventing duplicate images
if (normalizedExcludeUrls.has(normalizedUrl)) { if (normalizedExcludeUrls.has(normalizedUrl)) {
// Remove excluded plain URLs // Remove excluded plain URLs - they're already displayed by MediaAttachments
if (typeof console !== 'undefined') { if (typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Found excluded URL, removing:', url, 'normalized:', normalizedUrl); console.debug('MarkdownRenderer: Removing excluded URL from content:', url, 'normalized:', normalizedUrl);
} }
replacements.push({ fullMatch, replacement: '', index }); replacements.push({ fullMatch, replacement: '', index });
} else { } else {
if (typeof console !== 'undefined' && normalizedExcludeUrls.size > 0) { if (typeof console !== 'undefined' && normalizedExcludeUrls.size > 0) {
console.debug('MarkdownRenderer: URL not excluded, will convert:', url, 'normalized:', normalizedUrl, 'excluded set:', Array.from(normalizedExcludeUrls)); console.debug('MarkdownRenderer: URL not in exclusion list, will convert to HTML:', url, 'normalized:', normalizedUrl, 'excluded set:', Array.from(normalizedExcludeUrls));
} }
// Convert non-excluded plain URLs to HTML tags // Convert non-excluded plain URLs to HTML tags
const ext = match[2].toLowerCase(); const ext = match[2].toLowerCase();
@ -767,7 +769,9 @@
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url))); const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
let filtered = htmlContent; let filtered = htmlContent;
// Remove ALL img tags with excluded URLs // Remove ALL img tags with excluded URLs (aggressive cleanup)
// This is critical for kind 11 events to prevent duplicate images
const beforeCount = (filtered.match(/<img[^>]*>/gi) || []).length;
filtered = filtered.replace(/<img[^>]*>/gi, (match) => { filtered = filtered.replace(/<img[^>]*>/gi, (match) => {
const srcMatch = match.match(/src=["']([^"']+)["']/i); const srcMatch = match.match(/src=["']([^"']+)["']/i);
if (srcMatch) { if (srcMatch) {
@ -775,13 +779,17 @@
const normalizedSrc = normalizeUrl(src); const normalizedSrc = normalizeUrl(src);
if (normalizedExcludeUrls.has(normalizedSrc)) { if (normalizedExcludeUrls.has(normalizedSrc)) {
if (typeof console !== 'undefined') { if (typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Removing excluded img tag from content:', src); console.debug('MarkdownRenderer: Removing excluded img tag from HTML:', src, 'normalized:', normalizedSrc, 'excluded set:', Array.from(normalizedExcludeUrls));
} }
return ''; return ''; // Remove the img tag completely
} }
} }
return match; return match;
}); });
const afterCount = (filtered.match(/<img[^>]*>/gi) || []).length;
if (typeof console !== 'undefined' && beforeCount !== afterCount) {
console.debug('MarkdownRenderer: Exclusion filtering removed', beforeCount - afterCount, 'img tags');
}
// Remove links pointing to excluded image URLs // Remove links pointing to excluded image URLs
filtered = filtered.replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*?)>([^<]*?)<\/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => { filtered = filtered.replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*?)>([^<]*?)<\/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => {

75
src/lib/modules/discussions/DiscussionCard.svelte

@ -70,6 +70,77 @@
let showReplyForm = $state(false); let showReplyForm = $state(false);
let isLoggedIn = $derived(sessionManager.isLoggedIn()); let isLoggedIn = $derived(sessionManager.isLoggedIn());
// Normalize URL for comparison (same logic as MediaAttachments and MarkdownRenderer)
function normalizeUrl(url: string): string {
if (!url || typeof url !== 'string') return url;
try {
const parsed = new URL(url);
const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, '');
return normalized;
} catch {
return url.trim().replace(/\/$/, '').toLowerCase();
}
}
// Get media URLs that MediaAttachments will display (for fullView)
// This matches the logic in MediaAttachments.extractMedia() and FeedPost.mediaAttachmentUrls
const mediaAttachmentUrls = $derived.by(() => {
const urls: string[] = [];
const seen = new Set<string>();
const forceRender = isMediaKind;
// 1. Image tag (NIP-23)
const imageTag = thread.tags.find((t) => t[0] === 'image');
if (imageTag && imageTag[1]) {
const normalized = normalizeUrl(imageTag[1]);
if (!seen.has(normalized)) {
urls.push(imageTag[1]);
seen.add(normalized);
}
}
// 2. Extract from markdown content (images in markdown syntax)
const imageRegex = /!\[.*?\]\((.*?)\)/g;
let match;
while ((match = imageRegex.exec(thread.content)) !== null) {
const url = match[1];
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
}
}
// 3. Extract from AsciiDoc content (images in AsciiDoc syntax)
const asciidocImageRegex = /image::?([^\s\[\]]+)(?:\[[^\]]*\])?/g;
asciidocImageRegex.lastIndex = 0;
let asciidocMatch;
while ((asciidocMatch = asciidocImageRegex.exec(thread.content)) !== null) {
const url = asciidocMatch[1];
if (url.startsWith('http://') || url.startsWith('https://')) {
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
}
}
}
// 4. Extract plain image URLs from content (matching MediaAttachments logic)
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi;
urlRegex.lastIndex = 0;
while ((match = urlRegex.exec(thread.content)) !== null) {
const url = match[1];
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
}
}
return urls;
});
onMount(async () => { onMount(async () => {
await loadStats(); await loadStats();
}); });
@ -239,7 +310,7 @@
{#if shouldAutoRenderMedia} {#if shouldAutoRenderMedia}
<MediaAttachments event={thread} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} isFeedView={false} /> <MediaAttachments event={thread} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} isFeedView={false} />
{/if} {/if}
<MarkdownRenderer content={thread.content} event={thread} /> <MarkdownRenderer content={thread.content} event={thread} excludeMediaUrls={mediaAttachmentUrls} />
</div> </div>
</div> </div>
</a> </a>
@ -289,7 +360,7 @@
<div class="post-content mb-2"> <div class="post-content mb-2">
<MediaAttachments event={thread} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} /> <MediaAttachments event={thread} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} />
<MarkdownRenderer content={thread.content} event={thread} /> <MarkdownRenderer content={thread.content} event={thread} excludeMediaUrls={mediaAttachmentUrls} />
</div> </div>
{#if getTopics().length > 0} {#if getTopics().length > 0}

3
src/lib/modules/feed/FeedPost.svelte

@ -726,6 +726,7 @@
// Get media URLs that MediaAttachments will display (for fullView) // Get media URLs that MediaAttachments will display (for fullView)
// This matches the logic in MediaAttachments.extractMedia() // This matches the logic in MediaAttachments.extractMedia()
// Use $derived to ensure it updates reactively // Use $derived to ensure it updates reactively
// For kind 11 (discussion threads), we always extract ALL media URLs to ensure they're excluded from markdown
const mediaAttachmentUrls = $derived.by(() => { const mediaAttachmentUrls = $derived.by(() => {
const urls: string[] = []; const urls: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -997,7 +998,7 @@
<PollCard pollEvent={post} /> <PollCard pollEvent={post} />
{:else if post.content && post.content.trim()} {:else if post.content && post.content.trim()}
{#if typeof console !== 'undefined' && fullView} {#if typeof console !== 'undefined' && fullView}
{console.debug('FeedPost fullView: Media attachment URLs to exclude:', mediaAttachmentUrls, 'for post:', post.id)} {console.debug('FeedPost fullView: Media attachment URLs to exclude:', mediaAttachmentUrls, 'for post:', post.id, 'kind:', post.kind)}
{/if} {/if}
<MarkdownRenderer content={post.content} event={post} excludeMediaUrls={mediaAttachmentUrls} /> <MarkdownRenderer content={post.content} event={post} excludeMediaUrls={mediaAttachmentUrls} />
{:else if !isMediaKind && post.kind !== KIND.POLL} {:else if !isMediaKind && post.kind !== KIND.POLL}

49
src/lib/services/content/git-repo-fetcher.ts

@ -320,12 +320,32 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
const projectPath = `${owner}/${repo}`; const projectPath = `${owner}/${repo}`;
const encodedPath = encodeURIComponent(projectPath); const encodedPath = encodeURIComponent(projectPath);
const [repoData, branchesData, commitsData] = await Promise.all([ // Use proxy endpoint to avoid CORS issues
fetch(`${baseUrl}/projects/${encodedPath}`).then(r => r.json()), const [repoResponse, branchesResponse, commitsResponse] = await Promise.all([
fetch(`${baseUrl}/projects/${encodedPath}/repository/branches`).then(r => r.json()), fetch(`/api/gitea-proxy/projects/${encodedPath}?baseUrl=${encodeURIComponent(baseUrl)}`),
fetch(`${baseUrl}/projects/${encodedPath}/repository/commits?per_page=10`).then(r => r.json()) fetch(`/api/gitea-proxy/projects/${encodedPath}/repository/branches?baseUrl=${encodeURIComponent(baseUrl)}`),
fetch(`/api/gitea-proxy/projects/${encodedPath}/repository/commits?baseUrl=${encodeURIComponent(baseUrl)}&per_page=10`)
]); ]);
if (!repoResponse.ok) {
console.warn(`GitLab API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`);
return null;
}
const repoData = await repoResponse.json();
let branchesData: any[] = [];
let commitsData: any[] = [];
if (branchesResponse.ok) {
const data = await branchesResponse.json();
branchesData = Array.isArray(data) ? data : [];
}
if (commitsResponse.ok) {
const data = await commitsResponse.json();
commitsData = Array.isArray(data) ? data : [];
}
const branches: GitBranch[] = branchesData.map((b: any) => ({ const branches: GitBranch[] = branchesData.map((b: any) => ({
name: b.name, name: b.name,
commit: { commit: {
@ -346,7 +366,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
// Fetch file tree // Fetch file tree
let files: GitFile[] = []; let files: GitFile[] = [];
try { try {
const treeData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/tree?recursive=true&per_page=100`).then(r => r.json()); const treeData = await fetch(`/api/gitea-proxy/projects/${encodedPath}/repository/tree?baseUrl=${encodeURIComponent(baseUrl)}&recursive=true&per_page=100`).then(r => r.json());
files = treeData.map((item: any) => ({ files = treeData.map((item: any) => ({
name: item.name, name: item.name,
path: item.path, path: item.path,
@ -363,7 +383,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) { for (const readmeFile of readmeFiles) {
try { try {
const fileData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/files/${encodeURIComponent(readmeFile)}/raw?ref=${repoData.default_branch}`).then(r => { const fileData = await fetch(`/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(readmeFile)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${repoData.default_branch}`).then(r => {
if (!r.ok) throw new Error('Not found'); if (!r.ok) throw new Error('Not found');
return r.text(); return r.text();
}); });
@ -398,7 +418,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
// If found in tree, fetch it // If found in tree, fetch it
if (readmePath) { if (readmePath) {
try { try {
const fileData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/files/${encodeURIComponent(readmePath)}/raw?ref=${repoData.default_branch}`).then(r => { const fileData = await fetch(`/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(readmePath)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${repoData.default_branch}`).then(r => {
if (!r.ok) throw new Error('Not found'); if (!r.ok) throw new Error('Not found');
return r.text(); return r.text();
}); });
@ -436,8 +456,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> { async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> {
try { try {
// Use proxy endpoint to avoid CORS issues // Use proxy endpoint to avoid CORS issues
const proxyBaseUrl = encodeURIComponent(baseUrl); const repoResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}?baseUrl=${encodeURIComponent(baseUrl)}`);
const repoResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}`);
if (!repoResponse.ok) { if (!repoResponse.ok) {
console.warn(`Gitea API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`); console.warn(`Gitea API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`);
return null; return null;
@ -447,8 +466,8 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
const defaultBranch = repoData.default_branch || 'master'; const defaultBranch = repoData.default_branch || 'master';
const [branchesResponse, commitsResponse] = await Promise.all([ const [branchesResponse, commitsResponse] = await Promise.all([
fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/branches`).catch(() => null), fetch(`/api/gitea-proxy/repos/${owner}/${repo}/branches?baseUrl=${encodeURIComponent(baseUrl)}`).catch(() => null),
fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/commits?limit=10`).catch(() => null) fetch(`/api/gitea-proxy/repos/${owner}/${repo}/commits?baseUrl=${encodeURIComponent(baseUrl)}&limit=10`).catch(() => null)
]); ]);
let branchesData: any[] = []; let branchesData: any[] = [];
@ -501,7 +520,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
let files: GitFile[] = []; let files: GitFile[] = [];
try { try {
// Try the git/trees endpoint first (more complete) // Try the git/trees endpoint first (more complete)
const treeResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => null); const treeResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}/git/trees/${defaultBranch}?baseUrl=${encodeURIComponent(baseUrl)}&recursive=1`).catch(() => null);
if (treeResponse && treeResponse.ok) { if (treeResponse && treeResponse.ok) {
const treeData = await treeResponse.json(); const treeData = await treeResponse.json();
if (treeData.tree && Array.isArray(treeData.tree)) { if (treeData.tree && Array.isArray(treeData.tree)) {
@ -516,7 +535,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
} }
} else { } else {
// Fallback to contents endpoint (only root directory) // Fallback to contents endpoint (only root directory)
const contentsResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/contents?ref=${defaultBranch}`).catch(() => null); const contentsResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}/contents?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`).catch(() => null);
if (contentsResponse && contentsResponse.ok) { if (contentsResponse && contentsResponse.ok) {
const contentsData = await contentsResponse.json(); const contentsData = await contentsResponse.json();
if (Array.isArray(contentsData)) { if (Array.isArray(contentsData)) {
@ -539,7 +558,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) { for (const readmeFile of readmeFiles) {
try { try {
const fileResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`); const fileResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}/contents/${readmeFile}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`);
if (!fileResponse.ok) throw new Error('Not found'); if (!fileResponse.ok) throw new Error('Not found');
const fileData = await fileResponse.json(); const fileData = await fileResponse.json();
if (fileData.content) { if (fileData.content) {
@ -578,7 +597,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
// If found in tree, fetch it // If found in tree, fetch it
if (readmePath) { if (readmePath) {
try { try {
const fileResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/contents/${readmePath}?ref=${defaultBranch}`); const fileResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}/contents/${readmePath}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`);
if (!fileResponse.ok) throw new Error('Not found'); if (!fileResponse.ok) throw new Error('Not found');
const fileData = await fileResponse.json(); const fileData = await fileResponse.json();
if (fileData.content) { if (fileData.content) {

148
src/routes/api/gitea-proxy/[...path]/+server.ts

@ -1,35 +1,109 @@
import type { RequestHandler } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit';
/** /**
* Proxy endpoint for Gitea API requests to avoid CORS issues * Proxy endpoint for Git hosting API requests (Gitea, GitLab, OneDev, etc.) to avoid CORS issues
* Usage: /api/gitea-proxy/{baseUrl}/repos/{owner}/{repo}/... * Usage: /api/gitea-proxy/{apiPath}?baseUrl={baseUrl}
* Example: /api/gitea-proxy/https%3A%2F%2Fgit.imwald.eu/api/v1/repos/silberengel/aitherboard * Examples:
* - Gitea: /api/gitea-proxy/repos/silberengel/aitherboard/contents/README.adoc?baseUrl=https://git.imwald.eu/api/v1&ref=master
* - GitLab: /api/gitea-proxy/projects/owner%2Frepo/repository/files/path/raw?baseUrl=https://gitlab.com/api/v4&ref=master
* - OneDev: /api/gitea-proxy/repos/owner/repo/contents/README.adoc?baseUrl=https://onedev.example.com/api/v1&ref=master
*/ */
export const GET: RequestHandler = async ({ params, url }: { params: { path?: string }; url: URL }) => {
try { const CORS_HEADERS = {
// Reconstruct the full path from params 'Access-Control-Allow-Origin': '*',
const pathParts = params.path?.split('/') || []; 'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
// Extract base URL (first part should be the encoded base URL) } as const;
if (pathParts.length < 1) {
return new Response(JSON.stringify({ error: 'Missing base URL' }), { function createErrorResponse(message: string, status: number): Response {
status: 400, return new Response(JSON.stringify({ error: message }), {
headers: { 'Content-Type': 'application/json' } status,
headers: {
'Content-Type': 'application/json',
...CORS_HEADERS
}
}); });
} }
// Decode the base URL (it's URL-encoded) function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearchParams): string {
const baseUrl = decodeURIComponent(pathParts[0]); // Ensure baseUrl doesn't have a trailing slash
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
// For GitLab, both the project path and file paths need to be re-encoded
// SvelteKit splits URL-encoded paths into separate segments
let processedPath = apiPath;
if (apiPath.startsWith('projects/')) {
const parts = apiPath.split('/');
// GitLab project paths are: projects/{owner}/{repo}/...
// If we have at least 3 parts (projects, owner, repo), combine owner and repo
if (parts.length >= 3) {
const projectPath = `${parts[1]}/${parts[2]}`;
const encodedProjectPath = encodeURIComponent(projectPath);
// Reconstruct the API path (everything after the base URL) // Check if this is a file path: projects/{owner}/{repo}/repository/files/{file_path}/raw
const apiPath = pathParts.slice(1).join('/'); const filesIndex = parts.indexOf('files');
if (filesIndex !== -1 && filesIndex < parts.length - 1) {
// Found /repository/files/, encode the file path (everything between 'files' and 'raw')
const filePathParts = parts.slice(filesIndex + 1, parts.length - 1); // Exclude 'raw' at the end
const filePath = filePathParts.join('/');
// GitLab API requires the file path to be URL-encoded
// encodeURIComponent will encode slashes as %2F, which is what GitLab expects
const encodedFilePath = encodeURIComponent(filePath);
// Add query parameters from the original request // Debug logging
const queryString = url.search; console.log('[Gitea Proxy] File path parts:', filePathParts);
const fullUrl = `${baseUrl}/${apiPath}${queryString}`; console.log('[Gitea Proxy] File path (joined):', filePath);
console.log('[Gitea Proxy] Encoded file path:', encodedFilePath);
// Fetch from Gitea API // Reconstruct: projects/{encodedProjectPath}/repository/files/{encodedFilePath}/raw
const response = await fetch(fullUrl, { // Rebuild the path up to 'files', then add encoded file path and 'raw'
const beforeFiles = `projects/${encodedProjectPath}/repository/files`;
processedPath = `${beforeFiles}/${encodedFilePath}/${parts[parts.length - 1]}`;
} else {
// Not a file path, just re-encode the project path
processedPath = `projects/${encodedProjectPath}${parts.length > 3 ? '/' + parts.slice(3).join('/') : ''}`;
}
}
}
// Ensure processedPath starts with a slash
const cleanApiPath = processedPath.startsWith('/') ? processedPath : `/${processedPath}`;
// Construct query string (excluding baseUrl)
const queryParts: string[] = [];
for (const [key, value] of searchParams.entries()) {
if (key !== 'baseUrl') {
queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
// Construct the full URL manually to preserve encoding
// Note: We construct as string because new URL() with pathname assignment would decode %2F
return `${cleanBaseUrl}${cleanApiPath}${queryString}`;
}
export const GET: RequestHandler = async ({ params, url }) => {
try {
const baseUrl = url.searchParams.get('baseUrl');
const apiPath = params.path;
if (!baseUrl) {
return createErrorResponse('Missing baseUrl query parameter', 400);
}
if (!apiPath) {
return createErrorResponse('Missing API path', 400);
}
const targetUrl = buildTargetUrl(baseUrl, apiPath, url.searchParams);
// Debug logging (remove in production if needed)
console.log('[Gitea Proxy] Original path:', apiPath);
console.log('[Gitea Proxy] Target URL:', targetUrl);
const response = await fetch(targetUrl, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -37,41 +111,33 @@ export const GET: RequestHandler = async ({ params, url }: { params: { path?: st
} }
}); });
// Get response body
const contentType = response.headers.get('content-type') || 'application/json'; const contentType = response.headers.get('content-type') || 'application/json';
const body = await response.text(); const body = await response.text();
// Return response with CORS headers // Log error responses for debugging
if (!response.ok) {
console.error('[Gitea Proxy] Error response:', response.status, response.statusText);
console.error('[Gitea Proxy] Response body:', body.substring(0, 500));
}
return new Response(body, { return new Response(body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Access-Control-Allow-Origin': '*', ...CORS_HEADERS
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
} }
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Gitea proxy error:', message); console.error('Git hosting proxy error:', message);
return new Response(JSON.stringify({ error: message }), { return createErrorResponse(message, 500);
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
} }
}; };
export const OPTIONS: RequestHandler = async (): Promise<Response> => { export const OPTIONS: RequestHandler = async () => {
return new Response(null, { return new Response(null, {
status: 204, status: 204,
headers: { headers: CORS_HEADERS
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
}); });
}; };

11
svelte.config.js

@ -15,7 +15,16 @@ const config = {
precompress: true precompress: true
}), }),
prerender: { prerender: {
handleUnseenRoutes: 'ignore' handleUnseenRoutes: 'ignore',
handleHttpError: ({ path, referrer, message }) => {
// Ignore 404s for static assets during prerendering
// These will be available at runtime from the public directory
if (path === '/favicon.ico' || path === '/og-image.png') {
return;
}
// For other errors, throw to fail the build
throw new Error(`${message} (${path})`);
}
} }
} }
}; };

10
vite.config.ts

@ -5,6 +5,7 @@ import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import compression from 'vite-plugin-compression'; import compression from 'vite-plugin-compression';
export default defineConfig({ export default defineConfig({
publicDir: 'public',
plugins: [ plugins: [
sveltekit(), sveltekit(),
compression({ compression({
@ -115,7 +116,8 @@ export default defineConfig({
] ]
}, },
devOptions: { devOptions: {
enabled: false enabled: false,
suppressWarnings: true // Suppress service worker warnings in dev mode
} }
}), }),
{ {
@ -131,7 +133,11 @@ export default defineConfig({
], ],
server: { server: {
port: 5173, port: 5173,
strictPort: false strictPort: false,
fs: {
// Allow serving files from the project root (including static directory if needed)
allow: ['..']
}
}, },
build: { build: {
target: 'esnext', target: 'esnext',

Loading…
Cancel
Save