diff --git a/Dockerfile b/Dockerfile
index 5f0894e..646a4ad 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,6 +8,8 @@ COPY . .
# If ARG is not provided, ENV will be empty and config.ts will use defaults
ARG VITE_DEFAULT_RELAYS
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_THREAD_TIMEOUT_DAYS=${VITE_THREAD_TIMEOUT_DAYS}
RUN npm run build
diff --git a/README.md b/README.md
index 6a2732c..02f09f2 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
A decentralized messageboard built on the Nostr protocol.
-
+
**About**: [https://aitherboard.imwald.eu/about](https://aitherboard.imwald.eu/about)
diff --git a/docker-compose.yml b/docker-compose.yml
index 93bb24e..f44d38f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,16 +1,19 @@
services:
aitherboard:
container_name: aitherboard
- # Using pre-built image (recommended for production)
- image: silberengel/aitherboard:latest
- # Alternative: Build from source (for development)
- # build:
- # context: .
- # # Optional: override defaults from config.ts if needed
- # # Uncomment and modify if you want custom values:
- # # args:
- # # VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land"
- # # VITE_THREAD_TIMEOUT_DAYS: "30"
+ # Build from source (always rebuilds - use --no-cache flag when running)
+ build:
+ context: .
+ dockerfile: Dockerfile
+ # Optional: override defaults from config.ts if needed
+ # Uncomment and modify if you want custom values:
+ # args:
+ # VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land"
+ # 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:
- "9876:9876"
environment:
diff --git a/docker-rebuild.sh b/docker-rebuild.sh
new file mode 100755
index 0000000..b60eeb8
--- /dev/null
+++ b/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."
diff --git a/static/aither.png b/public/aither.png
similarity index 100%
rename from static/aither.png
rename to public/aither.png
diff --git a/static/apple-touch-icon-114x114.png b/public/apple-touch-icon-114x114.png
similarity index 100%
rename from static/apple-touch-icon-114x114.png
rename to public/apple-touch-icon-114x114.png
diff --git a/static/apple-touch-icon-120x120.png b/public/apple-touch-icon-120x120.png
similarity index 100%
rename from static/apple-touch-icon-120x120.png
rename to public/apple-touch-icon-120x120.png
diff --git a/static/apple-touch-icon-144x144.png b/public/apple-touch-icon-144x144.png
similarity index 100%
rename from static/apple-touch-icon-144x144.png
rename to public/apple-touch-icon-144x144.png
diff --git a/static/apple-touch-icon-152x152.png b/public/apple-touch-icon-152x152.png
similarity index 100%
rename from static/apple-touch-icon-152x152.png
rename to public/apple-touch-icon-152x152.png
diff --git a/static/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png
similarity index 100%
rename from static/apple-touch-icon-180x180.png
rename to public/apple-touch-icon-180x180.png
diff --git a/static/apple-touch-icon-57x57.png b/public/apple-touch-icon-57x57.png
similarity index 100%
rename from static/apple-touch-icon-57x57.png
rename to public/apple-touch-icon-57x57.png
diff --git a/static/apple-touch-icon-60x60.png b/public/apple-touch-icon-60x60.png
similarity index 100%
rename from static/apple-touch-icon-60x60.png
rename to public/apple-touch-icon-60x60.png
diff --git a/static/apple-touch-icon-72x72.png b/public/apple-touch-icon-72x72.png
similarity index 100%
rename from static/apple-touch-icon-72x72.png
rename to public/apple-touch-icon-72x72.png
diff --git a/static/apple-touch-icon-76x76.png b/public/apple-touch-icon-76x76.png
similarity index 100%
rename from static/apple-touch-icon-76x76.png
rename to public/apple-touch-icon-76x76.png
diff --git a/static/favicon.ico b/public/favicon.ico
similarity index 100%
rename from static/favicon.ico
rename to public/favicon.ico
diff --git a/public/healthz.json b/public/healthz.json
index 8fa95ea..7211eda 100644
--- a/public/healthz.json
+++ b/public/healthz.json
@@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.3.1",
- "buildTime": "2026-02-13T04:20:13.671Z",
+ "buildTime": "2026-02-14T07:05:05.090Z",
"gitCommit": "unknown",
- "timestamp": 1770956413671
+ "timestamp": 1771052705090
}
\ No newline at end of file
diff --git a/static/icons/arrow-left.svg b/public/icons/arrow-left.svg
similarity index 100%
rename from static/icons/arrow-left.svg
rename to public/icons/arrow-left.svg
diff --git a/static/icons/bookmark.svg b/public/icons/bookmark.svg
similarity index 100%
rename from static/icons/bookmark.svg
rename to public/icons/bookmark.svg
diff --git a/static/icons/check.svg b/public/icons/check.svg
similarity index 100%
rename from static/icons/check.svg
rename to public/icons/check.svg
diff --git a/static/icons/chevron-down.svg b/public/icons/chevron-down.svg
similarity index 100%
rename from static/icons/chevron-down.svg
rename to public/icons/chevron-down.svg
diff --git a/static/icons/chevron-up.svg b/public/icons/chevron-up.svg
similarity index 100%
rename from static/icons/chevron-up.svg
rename to public/icons/chevron-up.svg
diff --git a/static/icons/code.svg b/public/icons/code.svg
similarity index 100%
rename from static/icons/code.svg
rename to public/icons/code.svg
diff --git a/static/icons/copy.svg b/public/icons/copy.svg
similarity index 100%
rename from static/icons/copy.svg
rename to public/icons/copy.svg
diff --git a/static/icons/database.svg b/public/icons/database.svg
similarity index 100%
rename from static/icons/database.svg
rename to public/icons/database.svg
diff --git a/static/icons/download.svg b/public/icons/download.svg
similarity index 100%
rename from static/icons/download.svg
rename to public/icons/download.svg
diff --git a/static/icons/edit.svg b/public/icons/edit.svg
similarity index 100%
rename from static/icons/edit.svg
rename to public/icons/edit.svg
diff --git a/static/icons/eye.svg b/public/icons/eye.svg
similarity index 100%
rename from static/icons/eye.svg
rename to public/icons/eye.svg
diff --git a/static/icons/file-text.svg b/public/icons/file-text.svg
similarity index 100%
rename from static/icons/file-text.svg
rename to public/icons/file-text.svg
diff --git a/static/icons/heart.svg b/public/icons/heart.svg
similarity index 100%
rename from static/icons/heart.svg
rename to public/icons/heart.svg
diff --git a/static/icons/highlight.svg b/public/icons/highlight.svg
similarity index 100%
rename from static/icons/highlight.svg
rename to public/icons/highlight.svg
diff --git a/static/icons/image.svg b/public/icons/image.svg
similarity index 100%
rename from static/icons/image.svg
rename to public/icons/image.svg
diff --git a/static/icons/key.svg b/public/icons/key.svg
similarity index 100%
rename from static/icons/key.svg
rename to public/icons/key.svg
diff --git a/static/icons/link.svg b/public/icons/link.svg
similarity index 100%
rename from static/icons/link.svg
rename to public/icons/link.svg
diff --git a/static/icons/log-in.svg b/public/icons/log-in.svg
similarity index 100%
rename from static/icons/log-in.svg
rename to public/icons/log-in.svg
diff --git a/static/icons/log-out.svg b/public/icons/log-out.svg
similarity index 100%
rename from static/icons/log-out.svg
rename to public/icons/log-out.svg
diff --git a/static/icons/message-square.svg b/public/icons/message-square.svg
similarity index 100%
rename from static/icons/message-square.svg
rename to public/icons/message-square.svg
diff --git a/static/icons/moon.svg b/public/icons/moon.svg
similarity index 100%
rename from static/icons/moon.svg
rename to public/icons/moon.svg
diff --git a/static/icons/plus.svg b/public/icons/plus.svg
similarity index 100%
rename from static/icons/plus.svg
rename to public/icons/plus.svg
diff --git a/static/icons/radio.svg b/public/icons/radio.svg
similarity index 100%
rename from static/icons/radio.svg
rename to public/icons/radio.svg
diff --git a/static/icons/search.svg b/public/icons/search.svg
similarity index 100%
rename from static/icons/search.svg
rename to public/icons/search.svg
diff --git a/static/icons/send.svg b/public/icons/send.svg
similarity index 100%
rename from static/icons/send.svg
rename to public/icons/send.svg
diff --git a/static/icons/settings.svg b/public/icons/settings.svg
similarity index 100%
rename from static/icons/settings.svg
rename to public/icons/settings.svg
diff --git a/static/icons/share.svg b/public/icons/share.svg
similarity index 100%
rename from static/icons/share.svg
rename to public/icons/share.svg
diff --git a/static/icons/smile.svg b/public/icons/smile.svg
similarity index 100%
rename from static/icons/smile.svg
rename to public/icons/smile.svg
diff --git a/static/icons/sun.svg b/public/icons/sun.svg
similarity index 100%
rename from static/icons/sun.svg
rename to public/icons/sun.svg
diff --git a/static/icons/trash.svg b/public/icons/trash.svg
similarity index 100%
rename from static/icons/trash.svg
rename to public/icons/trash.svg
diff --git a/static/icons/upload.svg b/public/icons/upload.svg
similarity index 100%
rename from static/icons/upload.svg
rename to public/icons/upload.svg
diff --git a/static/icons/user.svg b/public/icons/user.svg
similarity index 100%
rename from static/icons/user.svg
rename to public/icons/user.svg
diff --git a/static/icons/video.svg b/public/icons/video.svg
similarity index 100%
rename from static/icons/video.svg
rename to public/icons/video.svg
diff --git a/static/icons/volume.svg b/public/icons/volume.svg
similarity index 100%
rename from static/icons/volume.svg
rename to public/icons/volume.svg
diff --git a/static/icons/x.svg b/public/icons/x.svg
similarity index 100%
rename from static/icons/x.svg
rename to public/icons/x.svg
diff --git a/static/icons/zap.svg b/public/icons/zap.svg
similarity index 100%
rename from static/icons/zap.svg
rename to public/icons/zap.svg
diff --git a/static/og-image.png b/public/og-image.png
similarity index 100%
rename from static/og-image.png
rename to public/og-image.png
diff --git a/src/lib/components/content/FileExplorer.svelte b/src/lib/components/content/FileExplorer.svelte
index 0b10ffd..77f04c3 100644
--- a/src/lib/components/content/FileExplorer.svelte
+++ b/src/lib/components/content/FileExplorer.svelte
@@ -105,7 +105,8 @@
const apiBase = baseHost.includes('gitlab.com')
? 'https://gitlab.com/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')) {
// OneDev API: similar to Gitea, uses /api/v1/repos/{owner}/{repo}/contents/{path}
@@ -113,7 +114,9 @@
if (match) {
const [, baseUrl, owner, repo] = match;
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 {
// Try Gitea/Forgejo pattern (handles .git suffix) - generic fallback
@@ -121,7 +124,9 @@
if (match) {
const [, baseUrl, owner, repo] = match;
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}`;
}
}
diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte
index 63ad966..b094855 100644
--- a/src/lib/components/content/MarkdownRenderer.svelte
+++ b/src/lib/components/content/MarkdownRenderer.svelte
@@ -520,6 +520,7 @@
}
// 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;
urlRegex.lastIndex = 0;
while ((match = urlRegex.exec(text)) !== null) {
@@ -540,15 +541,16 @@
}
const normalizedUrl = normalizeUrl(url);
+ // Check exclusion - this is critical for preventing duplicate images
if (normalizedExcludeUrls.has(normalizedUrl)) {
- // Remove excluded plain URLs
+ // Remove excluded plain URLs - they're already displayed by MediaAttachments
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 });
} else {
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
const ext = match[2].toLowerCase();
@@ -767,7 +769,9 @@
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
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(/
]*>/gi) || []).length;
filtered = filtered.replace(/
]*>/gi, (match) => {
const srcMatch = match.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
@@ -775,13 +779,17 @@
const normalizedSrc = normalizeUrl(src);
if (normalizedExcludeUrls.has(normalizedSrc)) {
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;
});
+ const afterCount = (filtered.match(/
]*>/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
filtered = filtered.replace(/]*?)href=["']([^"']+)["']([^>]*?)>([^<]*?)<\/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => {
diff --git a/src/lib/modules/discussions/DiscussionCard.svelte b/src/lib/modules/discussions/DiscussionCard.svelte
index 649d96d..4e615a1 100644
--- a/src/lib/modules/discussions/DiscussionCard.svelte
+++ b/src/lib/modules/discussions/DiscussionCard.svelte
@@ -70,6 +70,77 @@
let showReplyForm = $state(false);
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();
+ 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 () => {
await loadStats();
});
@@ -239,7 +310,7 @@
{#if shouldAutoRenderMedia}
{/if}
-
+
@@ -289,7 +360,7 @@
-
+
{#if getTopics().length > 0}
diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte
index 5e8b4f8..52a423f 100644
--- a/src/lib/modules/feed/FeedPost.svelte
+++ b/src/lib/modules/feed/FeedPost.svelte
@@ -726,6 +726,7 @@
// Get media URLs that MediaAttachments will display (for fullView)
// This matches the logic in MediaAttachments.extractMedia()
// 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 urls: string[] = [];
const seen = new Set();
@@ -997,7 +998,7 @@
{:else if post.content && post.content.trim()}
{#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}
{:else if !isMediaKind && post.kind !== KIND.POLL}
diff --git a/src/lib/services/content/git-repo-fetcher.ts b/src/lib/services/content/git-repo-fetcher.ts
index 1cea028..cc23f8d 100644
--- a/src/lib/services/content/git-repo-fetcher.ts
+++ b/src/lib/services/content/git-repo-fetcher.ts
@@ -320,11 +320,31 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
const projectPath = `${owner}/${repo}`;
const encodedPath = encodeURIComponent(projectPath);
- const [repoData, branchesData, commitsData] = await Promise.all([
- fetch(`${baseUrl}/projects/${encodedPath}`).then(r => r.json()),
- fetch(`${baseUrl}/projects/${encodedPath}/repository/branches`).then(r => r.json()),
- fetch(`${baseUrl}/projects/${encodedPath}/repository/commits?per_page=10`).then(r => r.json())
+ // Use proxy endpoint to avoid CORS issues
+ const [repoResponse, branchesResponse, commitsResponse] = await Promise.all([
+ fetch(`/api/gitea-proxy/projects/${encodedPath}?baseUrl=${encodeURIComponent(baseUrl)}`),
+ 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) => ({
name: b.name,
@@ -346,7 +366,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
// Fetch file tree
let files: GitFile[] = [];
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) => ({
name: item.name,
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'];
for (const readmeFile of readmeFiles) {
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');
return r.text();
});
@@ -398,7 +418,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
// If found in tree, fetch it
if (readmePath) {
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');
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 {
try {
// Use proxy endpoint to avoid CORS issues
- const proxyBaseUrl = encodeURIComponent(baseUrl);
- const repoResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}`);
+ const repoResponse = await fetch(`/api/gitea-proxy/repos/${owner}/${repo}?baseUrl=${encodeURIComponent(baseUrl)}`);
if (!repoResponse.ok) {
console.warn(`Gitea API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`);
return null;
@@ -447,8 +466,8 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
const defaultBranch = repoData.default_branch || 'master';
const [branchesResponse, commitsResponse] = await Promise.all([
- fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/branches`).catch(() => null),
- fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/commits?limit=10`).catch(() => null)
+ fetch(`/api/gitea-proxy/repos/${owner}/${repo}/branches?baseUrl=${encodeURIComponent(baseUrl)}`).catch(() => null),
+ fetch(`/api/gitea-proxy/repos/${owner}/${repo}/commits?baseUrl=${encodeURIComponent(baseUrl)}&limit=10`).catch(() => null)
]);
let branchesData: any[] = [];
@@ -501,7 +520,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
let files: GitFile[] = [];
try {
// 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) {
const treeData = await treeResponse.json();
if (treeData.tree && Array.isArray(treeData.tree)) {
@@ -516,7 +535,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
}
} else {
// 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) {
const contentsData = await contentsResponse.json();
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'];
for (const readmeFile of readmeFiles) {
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');
const fileData = await fileResponse.json();
if (fileData.content) {
@@ -578,7 +597,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
// If found in tree, fetch it
if (readmePath) {
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');
const fileData = await fileResponse.json();
if (fileData.content) {
diff --git a/src/routes/api/gitea-proxy/[...path]/+server.ts b/src/routes/api/gitea-proxy/[...path]/+server.ts
index b29781c..d9688f0 100644
--- a/src/routes/api/gitea-proxy/[...path]/+server.ts
+++ b/src/routes/api/gitea-proxy/[...path]/+server.ts
@@ -1,35 +1,109 @@
import type { RequestHandler } from '@sveltejs/kit';
/**
- * Proxy endpoint for Gitea API requests to avoid CORS issues
- * Usage: /api/gitea-proxy/{baseUrl}/repos/{owner}/{repo}/...
- * Example: /api/gitea-proxy/https%3A%2F%2Fgit.imwald.eu/api/v1/repos/silberengel/aitherboard
+ * Proxy endpoint for Git hosting API requests (Gitea, GitLab, OneDev, etc.) to avoid CORS issues
+ * Usage: /api/gitea-proxy/{apiPath}?baseUrl={baseUrl}
+ * 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 {
- // Reconstruct the full path from params
- const pathParts = params.path?.split('/') || [];
+
+const CORS_HEADERS = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type'
+} as const;
+
+function createErrorResponse(message: string, status: number): Response {
+ return new Response(JSON.stringify({ error: message }), {
+ status,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...CORS_HEADERS
+ }
+ });
+}
+
+function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearchParams): string {
+ // 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('/');
- // Extract base URL (first part should be the encoded base URL)
- if (pathParts.length < 1) {
- return new Response(JSON.stringify({ error: 'Missing base URL' }), {
- status: 400,
- headers: { 'Content-Type': 'application/json' }
- });
+ // 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);
+
+ // Check if this is a file path: projects/{owner}/{repo}/repository/files/{file_path}/raw
+ 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);
+
+ // Debug logging
+ console.log('[Gitea Proxy] File path parts:', filePathParts);
+ console.log('[Gitea Proxy] File path (joined):', filePath);
+ console.log('[Gitea Proxy] Encoded file path:', encodedFilePath);
+
+ // Reconstruct: projects/{encodedProjectPath}/repository/files/{encodedFilePath}/raw
+ // 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}`;
+}
- // Decode the base URL (it's URL-encoded)
- const baseUrl = decodeURIComponent(pathParts[0]);
+export const GET: RequestHandler = async ({ params, url }) => {
+ try {
+ const baseUrl = url.searchParams.get('baseUrl');
+ const apiPath = params.path;
- // Reconstruct the API path (everything after the base URL)
- const apiPath = pathParts.slice(1).join('/');
+ if (!baseUrl) {
+ return createErrorResponse('Missing baseUrl query parameter', 400);
+ }
- // Add query parameters from the original request
- const queryString = url.search;
- const fullUrl = `${baseUrl}/${apiPath}${queryString}`;
+ if (!apiPath) {
+ return createErrorResponse('Missing API path', 400);
+ }
- // Fetch from Gitea API
- const response = await fetch(fullUrl, {
+ 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',
headers: {
'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 body = await response.text();
+
+ // 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 response with CORS headers
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': contentType,
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type'
+ ...CORS_HEADERS
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
- console.error('Gitea proxy error:', message);
- return new Response(JSON.stringify({ error: message }), {
- status: 500,
- headers: {
- 'Content-Type': 'application/json',
- 'Access-Control-Allow-Origin': '*'
- }
- });
+ console.error('Git hosting proxy error:', message);
+ return createErrorResponse(message, 500);
}
};
-export const OPTIONS: RequestHandler = async (): Promise => {
+export const OPTIONS: RequestHandler = async () => {
return new Response(null, {
status: 204,
- headers: {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type'
- }
+ headers: CORS_HEADERS
});
};
diff --git a/svelte.config.js b/svelte.config.js
index 3fd1dae..7f39c07 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -15,7 +15,16 @@ const config = {
precompress: true
}),
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})`);
+ }
}
}
};
diff --git a/vite.config.ts b/vite.config.ts
index d61c387..7b1e2bf 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,6 +5,7 @@ import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import compression from 'vite-plugin-compression';
export default defineConfig({
+ publicDir: 'public',
plugins: [
sveltekit(),
compression({
@@ -115,7 +116,8 @@ export default defineConfig({
]
},
devOptions: {
- enabled: false
+ enabled: false,
+ suppressWarnings: true // Suppress service worker warnings in dev mode
}
}),
{
@@ -131,7 +133,11 @@ export default defineConfig({
],
server: {
port: 5173,
- strictPort: false
+ strictPort: false,
+ fs: {
+ // Allow serving files from the project root (including static directory if needed)
+ allow: ['..']
+ }
},
build: {
target: 'esnext',