Browse Source

bug-fixes

master
Silberengel 4 weeks ago
parent
commit
10559048d0
  1. 4
      src/app.css
  2. 149
      src/lib/components/content/FileExplorer.svelte
  3. 103
      src/lib/components/modals/EventJsonModal.svelte
  4. 102
      src/lib/services/content/git-repo-fetcher.ts
  5. 263
      src/routes/api/gitea-proxy/[...path]/+server.ts
  6. 32
      src/routes/repos/+page.svelte
  7. 148
      src/routes/repos/[naddr]/+page.svelte
  8. 1
      static/changelog.yaml
  9. 4
      static/healthz.json

4
src/app.css

@ -1,3 +1,7 @@
/* Custom highlight.js theme for code blocks, JSON previews, and markdown */
/* @import must come before all other statements */
@import './lib/styles/highlight-theme.css';
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@tailwind base; @tailwind base;
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */

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

@ -3,7 +3,6 @@
import { fetchGitHubApi } from '../../services/github-api.js'; import { fetchGitHubApi } from '../../services/github-api.js';
// @ts-ignore - highlight.js default export works at runtime // @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
interface Props { interface Props {
@ -34,13 +33,36 @@
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const part = parts[i]; const part = parts[i];
if (i === parts.length - 1) { const isLast = i === parts.length - 1;
// File const isDirectory = file.type === 'dir';
current[part] = file;
if (isLast) {
// Last part of path
if (isDirectory) {
// Directory - ensure it's an object (not a file)
if (!current[part] || (current[part].path && !current[part]._isDir)) {
current[part] = {};
}
// Mark as directory
current[part]._isDir = true;
current[part]._path = file.path;
} else {
// File - store the GitFile object
// Only overwrite if current entry is a directory placeholder without children
if (!current[part] || (current[part]._isDir && Object.keys(current[part]).length <= 2)) {
current[part] = file;
}
}
} else { } else {
// Directory // Intermediate path segment - must be a directory
if (!current[part] || current[part].path) { if (!current[part]) {
current[part] = {}; current[part] = {};
} else if (current[part].path && !current[part]._isDir) {
// This was stored as a file, but we need it as a directory
// This shouldn't happen, but convert it
const existingFile = current[part];
current[part] = {};
current[part][existingFile.name] = existingFile;
} }
current = current[part]; current = current[part];
} }
@ -106,7 +128,8 @@
? 'https://gitlab.com/api/v4' ? 'https://gitlab.com/api/v4'
: `${baseHost}/api/v4`; : `${baseHost}/api/v4`;
// Use proxy endpoint to avoid CORS issues // 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}`; // Pass file path with actual slashes - proxy will handle encoding correctly
apiUrl = `/api/gitea-proxy/projects/${projectPath}/repository/files/${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}
@ -147,15 +170,30 @@
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
} }
const data = await response.json(); // Check content type to determine how to parse the response
const contentType = response.headers.get('content-type') || '';
// GitHub returns base64 encoded content, GitLab/Gitea return raw text if (url.includes('github.com')) {
if (data.content) { // GitHub API returns JSON with base64 encoded content
// GitHub format const data = await response.json();
fileContent = atob(data.content.replace(/\s/g, '')); if (data.content) {
fileContent = atob(data.content.replace(/\s/g, ''));
}
} else if (url.includes('gitlab') && apiUrl.includes('/raw')) {
// GitLab raw file endpoint returns plain text
fileContent = await response.text();
} else if (contentType.includes('application/json')) {
// Other platforms that return JSON (like Gitea contents API)
const data = await response.json();
if (data.content) {
// Some APIs return base64 content in JSON
fileContent = atob(data.content.replace(/\s/g, ''));
} else if (typeof data === 'string') {
fileContent = data;
}
} else { } else {
// GitLab/Gitea format (raw text) // Default to text for raw file endpoints
fileContent = typeof data === 'string' ? data : await response.text(); fileContent = await response.text();
} }
} catch (error) { } catch (error) {
console.error('Error fetching file content:', error); console.error('Error fetching file content:', error);
@ -247,8 +285,8 @@
'dockerfile': 'dockerfile', 'makefile': 'makefile', 'cmake': 'cmake', 'dockerfile': 'dockerfile', 'makefile': 'makefile', 'cmake': 'cmake',
'gradle': 'gradle', 'maven': 'xml', 'pom': 'xml', 'gradle': 'gradle', 'maven': 'xml', 'pom': 'xml',
'toml': 'toml', 'ini': 'ini', 'conf': 'ini', 'config': 'ini', 'toml': 'toml', 'ini': 'ini', 'conf': 'ini', 'config': 'ini',
'properties': 'properties', 'env': 'bash', 'gitignore': 'gitignore', 'properties': 'properties', 'env': 'bash', 'gitignore': 'plaintext',
'dockerignore': 'gitignore', 'editorconfig': 'ini', 'eslintrc': 'json', 'prettierrc': 'json' 'dockerignore': 'plaintext', 'editorconfig': 'ini', 'eslintrc': 'json', 'prettierrc': 'json'
}; };
return languageMap[ext] || 'plaintext'; return languageMap[ext] || 'plaintext';
} }
@ -286,8 +324,22 @@
$effect(() => { $effect(() => {
if (fileContent && selectedFile && isCodeFile(selectedFile) && codeRef) { if (fileContent && selectedFile && isCodeFile(selectedFile) && codeRef) {
const language = getLanguageFromExtension(selectedFile); const language = getLanguageFromExtension(selectedFile);
codeRef.innerHTML = hljs.highlight(fileContent, { language }).value; try {
codeRef.className = `language-${language}`; // Check if language is supported, fallback to plaintext if not
if (hljs.getLanguage(language)) {
codeRef.innerHTML = hljs.highlight(fileContent, { language }).value;
codeRef.className = `language-${language}`;
} else {
// Language not supported, use plaintext
codeRef.innerHTML = hljs.highlight(fileContent, { language: 'plaintext' }).value;
codeRef.className = 'language-plaintext';
}
} catch (error) {
// If highlighting fails, just display plain text
console.warn(`Failed to highlight code with language '${language}':`, error);
codeRef.textContent = fileContent;
codeRef.className = 'language-plaintext';
}
} }
}); });
</script> </script>
@ -534,7 +586,8 @@
.file-explorer { .file-explorer {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
height: 600px; height: 1000px;
min-height: 800px;
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
@ -767,8 +820,8 @@
.file-content-code { .file-content-code {
margin: 0; margin: 0;
background: #1e1e1e !important; /* VS Code dark background, same as JSON preview */ background: #000000 !important; /* Pure black background */
border: 1px solid #3e3e3e; border: 1px solid #333333;
border-radius: 4px; border-radius: 4px;
padding: 1rem; padding: 1rem;
overflow-x: auto; overflow-x: auto;
@ -777,60 +830,15 @@
} }
:global(.dark) .file-content-code { :global(.dark) .file-content-code {
background: #1e1e1e !important; background: #000000 !important; /* Pure black background */
border-color: #3e3e3e; border-color: #333333;
} }
.file-content-code code { .file-content-code code {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
padding: 0; padding: 0;
background: transparent !important; /* Theme colors are defined in highlight-theme.css */
color: #d4d4d4; /* VS Code text color */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem;
line-height: 1.5;
}
.file-content-code :global(code.hljs),
.file-content-code :global(code.hljs *),
.file-content-code :global(code.hljs span),
.file-content-code :global(code.hljs .hljs-keyword),
.file-content-code :global(code.hljs .hljs-string),
.file-content-code :global(code.hljs .hljs-comment),
.file-content-code :global(code.hljs .hljs-number),
.file-content-code :global(code.hljs .hljs-function),
.file-content-code :global(code.hljs .hljs-variable),
.file-content-code :global(code.hljs .hljs-class),
.file-content-code :global(code.hljs .hljs-title),
.file-content-code :global(code.hljs .hljs-attr),
.file-content-code :global(code.hljs .hljs-tag),
.file-content-code :global(code.hljs .hljs-name),
.file-content-code :global(code.hljs .hljs-selector-id),
.file-content-code :global(code.hljs .hljs-selector-class),
.file-content-code :global(code.hljs .hljs-attribute),
.file-content-code :global(code.hljs .hljs-built_in),
.file-content-code :global(code.hljs .hljs-literal),
.file-content-code :global(code.hljs .hljs-type),
.file-content-code :global(code.hljs .hljs-property),
.file-content-code :global(code.hljs .hljs-operator),
.file-content-code :global(code.hljs .hljs-punctuation),
.file-content-code :global(code.hljs .hljs-meta),
.file-content-code :global(code.hljs .hljs-doctag),
.file-content-code :global(code.hljs .hljs-section),
.file-content-code :global(code.hljs .hljs-addition),
.file-content-code :global(code.hljs .hljs-deletion),
.file-content-code :global(code.hljs .hljs-emphasis),
.file-content-code :global(code.hljs .hljs-strong) {
text-shadow: none !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
font-smoothing: antialiased !important;
text-rendering: geometricPrecision !important;
transform: translateZ(0);
backface-visibility: hidden;
filter: none !important;
will-change: auto;
} }
.file-image-container { .file-image-container {
@ -919,7 +927,8 @@
.file-explorer { .file-explorer {
flex-direction: column; flex-direction: column;
height: auto; height: auto;
max-height: 800px; min-height: 800px;
max-height: 1200px;
} }
.file-tree-panel { .file-tree-panel {

103
src/lib/components/modals/EventJsonModal.svelte

@ -3,7 +3,6 @@
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
// @ts-ignore - highlight.js default export works at runtime // @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
interface Props { interface Props {
open?: boolean; open?: boolean;
@ -13,6 +12,7 @@
let { open = $bindable(false), event = $bindable(null) }: Props = $props(); let { open = $bindable(false), event = $bindable(null) }: Props = $props();
let jsonText = $derived(event ? JSON.stringify(event, null, 2) : ''); let jsonText = $derived(event ? JSON.stringify(event, null, 2) : '');
let copied = $state(false); let copied = $state(false);
let wordWrap = $state(true); // Default to word-wrap enabled
let jsonPreviewRef: HTMLElement | null = $state(null); let jsonPreviewRef: HTMLElement | null = $state(null);
// Highlight JSON when it changes // Highlight JSON when it changes
@ -72,13 +72,25 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Event JSON</h2> <h2>Event JSON</h2>
<button onclick={close} class="close-button" aria-label="Close"> <div class="header-actions">
<Icon name="x" size={20} /> <button
</button> onclick={() => wordWrap = !wordWrap}
class="word-wrap-button"
class:active={wordWrap}
aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<Icon name="code" size={18} />
<span>{wordWrap ? 'Wrap: ON' : 'Wrap: OFF'}</span>
</button>
<button onclick={close} class="close-button" aria-label="Close">
<Icon name="x" size={20} />
</button>
</div>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<pre class="json-preview"><code bind:this={jsonPreviewRef} class="language-json">{jsonText}</code></pre> <pre class="json-preview" class:word-wrap={wordWrap}><code bind:this={jsonPreviewRef} class="language-json">{jsonText}</code></pre>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@ -162,6 +174,12 @@
flex-shrink: 0; flex-shrink: 0;
} }
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.modal-header { .modal-header {
padding: 0.75rem; padding: 0.75rem;
@ -186,6 +204,47 @@
color: var(--fog-dark-text, #f1f5f9); color: var(--fog-dark-text, #f1f5f9);
} }
.word-wrap-button {
background: var(--fog-border, #e5e7eb);
border: none;
border-radius: 4px;
cursor: pointer;
padding: 0.375rem 0.75rem;
color: var(--fog-text, #1f2937);
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
transition: background-color 0.2s;
}
.word-wrap-button:hover {
background: var(--fog-highlight, #f3f4f6);
}
.word-wrap-button.active {
background: var(--fog-accent, #64748b);
color: white;
}
.word-wrap-button.active:hover {
background: var(--fog-accent-dark, #475569);
}
:global(.dark) .word-wrap-button {
background: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .word-wrap-button:hover {
background: var(--fog-dark-highlight, #475569);
}
:global(.dark) .word-wrap-button.active {
background: var(--fog-accent, #64748b);
color: white;
}
.close-button { .close-button {
background: none; background: none;
border: none; border: none;
@ -211,24 +270,50 @@
} }
.json-preview { .json-preview {
background: #1e1e1e !important; /* VS Code dark background, same as code blocks */ background: #000000 !important; /* Pure black background - uses global highlight theme */
border: 1px solid #3e3e3e; border: 1px solid #333333;
border-radius: 4px; border-radius: 4px;
padding: 1rem; padding: 1rem;
margin: 0; margin: 0;
overflow-x: auto; overflow-x: auto;
max-height: 60vh; max-height: 60vh;
white-space: pre;
}
.json-preview.word-wrap {
overflow-x: visible !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
} }
.json-preview code { .json-preview code {
display: block; display: block;
overflow-x: auto;
padding: 0; padding: 0;
background: transparent !important; background: transparent !important;
color: #d4d4d4; /* VS Code text color */ /* Colors are defined in highlight-theme.css */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
white-space: pre;
}
.json-preview.word-wrap code {
overflow-x: visible !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
}
.json-preview.word-wrap :global(code.hljs),
.json-preview.word-wrap :global(code.hljs *) {
overflow-x: visible !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
} }
@media (max-width: 768px) { @media (max-width: 768px) {

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

@ -339,6 +339,13 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
if (branchesResponse.ok) { if (branchesResponse.ok) {
const data = await branchesResponse.json(); const data = await branchesResponse.json();
branchesData = Array.isArray(data) ? data : []; branchesData = Array.isArray(data) ? data : [];
// Check if default branch exists in branches list
if (repoData.default_branch && branchesData.length > 0) {
const defaultBranchExists = branchesData.some((b: any) => b.name === repoData.default_branch);
if (!defaultBranchExists && branchesData.length > 0) {
console.warn(`[GitLab] Default branch '${repoData.default_branch}' not found, first branch is: ${branchesData[0].name}`);
}
}
} }
if (commitsResponse.ok) { if (commitsResponse.ok) {
@ -363,17 +370,83 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
date: c.committed_date date: c.committed_date
})); }));
// Fetch file tree // Fetch file tree with pagination support
// GitLab API supports keyset pagination - fetch all pages to get complete tree
let files: GitFile[] = []; let files: GitFile[] = [];
try { try {
const treeData = await fetch(`/api/gitea-proxy/projects/${encodedPath}/repository/tree?baseUrl=${encodeURIComponent(baseUrl)}&recursive=true&per_page=100`).then(r => r.json()); let pageToken: string | null = null;
files = treeData.map((item: any) => ({ let hasMore = true;
name: item.name, let pageCount = 0;
path: item.path, const maxPages = 50; // Safety limit to prevent infinite loops
type: item.type === 'tree' ? 'dir' : 'file',
size: item.size while (hasMore && pageCount < maxPages) {
})); pageCount++;
} catch { let treeUrl = `/api/gitea-proxy/projects/${encodedPath}/repository/tree?baseUrl=${encodeURIComponent(baseUrl)}&recursive=true&per_page=100&pagination=keyset`;
if (pageToken) {
treeUrl += `&page_token=${encodeURIComponent(pageToken)}`;
}
const treeResponse = await fetch(treeUrl);
if (!treeResponse.ok) {
console.warn(`GitLab tree API error: ${treeResponse.status} ${treeResponse.statusText}`);
break;
}
const treeData = await treeResponse.json();
if (!Array.isArray(treeData)) {
console.warn('GitLab tree API returned non-array response:', treeData);
break;
}
// Process items from this page
const pageFiles: GitFile[] = treeData.map((item: any) => {
// GitLab uses 'tree' for directories and 'blob' for files
const isDir = item.type === 'tree';
return {
name: item.name,
path: item.path,
type: (isDir ? 'dir' : 'file') as 'dir' | 'file',
size: item.size
};
});
files = files.concat(pageFiles);
// Check if there are more pages
// GitLab returns Link header for pagination, or we can check if we got exactly per_page items
const linkHeader = treeResponse.headers.get('Link');
if (linkHeader && linkHeader.includes('rel="next"')) {
// Extract next page token from Link header if available
const nextMatch = linkHeader.match(/<[^>]+page_token=([^&>]+)[^>]*>; rel="next"/);
if (nextMatch) {
pageToken = decodeURIComponent(nextMatch[1]);
hasMore = true;
} else {
hasMore = false;
}
} else if (treeData.length === 100) {
// If we got exactly 100 items, there might be more pages
// Use the last item's id as the next page token (keyset pagination)
const lastItem = treeData[treeData.length - 1];
if (lastItem && lastItem.id) {
pageToken = lastItem.id;
hasMore = true;
} else {
hasMore = false;
}
} else {
// Got fewer than per_page items, this is the last page
hasMore = false;
}
}
if (pageCount >= maxPages) {
console.warn(`[GitLab] Reached max pages limit (${maxPages}), stopping pagination`);
}
} catch (error) {
console.error('GitLab tree fetch failed:', error);
// Tree fetch failed // Tree fetch failed
} }
@ -381,10 +454,15 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
// First try root directory (most common case) // First try root directory (most common case)
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
const defaultBranch = repoData.default_branch || 'master';
for (const readmeFile of readmeFiles) { for (const readmeFile of readmeFiles) {
try { try {
const fileData = await fetch(`/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(readmeFile)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${repoData.default_branch}`).then(r => { const readmeUrl = `/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(readmeFile)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`;
if (!r.ok) throw new Error('Not found'); const fileData = await fetch(readmeUrl).then(r => {
if (!r.ok) {
throw new Error('Not found');
}
return r.text(); return r.text();
}); });
readme = { readme = {
@ -393,7 +471,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'
}; };
break; // Found a README, stop searching break; // Found a README, stop searching
} catch { } catch (error) {
continue; // Try next file continue; // Try next file
} }
} }

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

@ -5,7 +5,8 @@ import type { RequestHandler } from '@sveltejs/kit';
* Usage: /api/gitea-proxy/{apiPath}?baseUrl={baseUrl} * Usage: /api/gitea-proxy/{apiPath}?baseUrl={baseUrl}
* Examples: * Examples:
* - Gitea: /api/gitea-proxy/repos/silberengel/aitherboard/contents/README.adoc?baseUrl=https://git.imwald.eu/api/v1&ref=master * - 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 * - GitLab: /api/gitea-proxy/projects/owner%2Frepo/repository/files/path/to/file/raw?baseUrl=https://gitlab.com/api/v4&ref=master
* (For gitlab.com, this is automatically converted to: https://gitlab.com/owner/repo/-/raw/master/path/to/file?ref_type=heads)
* - OneDev: /api/gitea-proxy/repos/owner/repo/contents/README.adoc?baseUrl=https://onedev.example.com/api/v1&ref=master * - OneDev: /api/gitea-proxy/repos/owner/repo/contents/README.adoc?baseUrl=https://onedev.example.com/api/v1&ref=master
*/ */
@ -29,40 +30,224 @@ function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearc
// Ensure baseUrl doesn't have a trailing slash // Ensure baseUrl doesn't have a trailing slash
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
// For GitLab, both the project path and file paths need to be re-encoded // Handle GitLab raw file requests - convert from API v4 format to raw file URL format
// SvelteKit splits URL-encoded paths into separate segments // GitLab raw file format: https://gitlab.com/{owner}/{repo}/-/raw/{ref}/{file_path}?ref_type=heads
// API v4 format: projects/{owner}/{repo}/repository/files/{file_path}/raw
if (apiPath.startsWith('projects/') && baseUrl.includes('gitlab.com')) {
const parts = apiPath.split('/');
if (parts.length >= 2) {
// Determine if project path is already encoded (contains %2F) or split across parts
let projectPath: string;
if (parts[1].includes('%2F') || parts[1].includes('%2f')) {
// Project path is already encoded in parts[1] (e.g., "Pleb5%2Fjumble-fork-test")
try {
projectPath = decodeURIComponent(parts[1]);
} catch {
projectPath = parts[1];
}
// Remaining parts start from index 2
const remainingParts = parts.slice(2);
// Check if this is a file path request
const filesIndex = remainingParts.indexOf('files');
if (filesIndex !== -1 && filesIndex < remainingParts.length - 1) {
// This is a file path: projects/{encodedProjectPath}/repository/files/{file_path}/raw
// Extract file path segments (everything between 'files' and 'raw')
const filePathParts = remainingParts.slice(filesIndex + 1, remainingParts.length - 1);
// Decode any already-encoded segments
const decodedSegments = filePathParts.map(segment => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
});
// Join with slashes to get the actual file path
const filePath = decodedSegments.join('/');
// Get ref from query params (default to 'master' if not provided)
const ref = searchParams.get('ref') || 'master';
// Extract GitLab host from baseUrl (e.g., https://gitlab.com or https://git.example.com)
const gitlabHost = cleanBaseUrl.replace('/api/v4', '').replace('/api/v3', '');
// Construct GitLab raw file URL format
const rawUrl = `${gitlabHost}/${projectPath}/-/raw/${ref}/${filePath}`;
// Add ref_type=heads query parameter
const queryParts: string[] = ['ref_type=heads'];
for (const [key, value] of searchParams.entries()) {
if (key !== 'baseUrl' && key !== 'ref') {
queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
const fullUrl = `${rawUrl}${queryString}`;
return fullUrl;
}
} else if (parts.length >= 3) {
// Project path is split: parts[1] = owner, parts[2] = repo
try {
projectPath = decodeURIComponent(parts[1]) + '/' + decodeURIComponent(parts[2]);
} catch {
projectPath = `${parts[1]}/${parts[2]}`;
}
// Remaining parts start from index 3
const remainingParts = parts.slice(3);
// Check if this is a file path request
const filesIndex = remainingParts.indexOf('files');
if (filesIndex !== -1 && filesIndex < remainingParts.length - 1) {
// This is a file path: projects/{owner}/{repo}/repository/files/{file_path}/raw
// Extract file path segments (everything between 'files' and 'raw')
const filePathParts = remainingParts.slice(filesIndex + 1, remainingParts.length - 1);
// Decode any already-encoded segments
const decodedSegments = filePathParts.map(segment => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
});
// Join with slashes to get the actual file path
const filePath = decodedSegments.join('/');
// Get ref from query params (default to 'master' if not provided)
const ref = searchParams.get('ref') || 'master';
// Extract GitLab host from baseUrl (e.g., https://gitlab.com or https://git.example.com)
const gitlabHost = cleanBaseUrl.replace('/api/v4', '').replace('/api/v3', '');
// Construct GitLab raw file URL format
const rawUrl = `${gitlabHost}/${projectPath}/-/raw/${ref}/${filePath}`;
// Add ref_type=heads query parameter
const queryParts: string[] = ['ref_type=heads'];
for (const [key, value] of searchParams.entries()) {
if (key !== 'baseUrl' && key !== 'ref') {
queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
const fullUrl = `${rawUrl}${queryString}`;
return fullUrl;
}
}
}
}
// Handle GitLab API paths (non-file requests, or non-gitlab.com instances)
// GitLab format: projects/{owner}/{repo}/repository/files/{file_path}/raw
// - Project path (owner/repo) MUST be encoded as owner%2Frepo
// - File path MUST be URL-encoded with %2F for slashes (GitLab API requirement)
let processedPath = apiPath; let processedPath = apiPath;
if (apiPath.startsWith('projects/')) { if (apiPath.startsWith('projects/')) {
const parts = apiPath.split('/'); const parts = apiPath.split('/');
// GitLab project paths are: projects/{owner}/{repo}/... if (parts.length >= 2) {
// If we have at least 3 parts (projects, owner, repo), combine owner and repo // Determine if project path is already encoded (contains %2F) or split across parts
if (parts.length >= 3) { let encodedProjectPath: string;
const projectPath = `${parts[1]}/${parts[2]}`;
const encodedProjectPath = encodeURIComponent(projectPath); if (parts[1].includes('%2F') || parts[1].includes('%2f')) {
// Project path is already encoded in parts[1] (e.g., "Pleb5%2Fjumble-fork-test")
// Check if this is a file path: projects/{owner}/{repo}/repository/files/{file_path}/raw encodedProjectPath = parts[1];
const filesIndex = parts.indexOf('files'); // Remaining parts start from index 2
if (filesIndex !== -1 && filesIndex < parts.length - 1) { const remainingParts = parts.slice(2);
// 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 // Check if this is a file path request
const filePath = filePathParts.join('/'); const filesIndex = remainingParts.indexOf('files');
// GitLab API requires the file path to be URL-encoded if (filesIndex !== -1 && filesIndex < remainingParts.length - 1) {
// encodeURIComponent will encode slashes as %2F, which is what GitLab expects // This is a file path: projects/{encodedProjectPath}/repository/files/{file_path}/raw
const encodedFilePath = encodeURIComponent(filePath); // Extract file path segments (everything between 'files' and 'raw')
const filePathParts = remainingParts.slice(filesIndex + 1, remainingParts.length - 1);
// Debug logging
console.log('[Gitea Proxy] File path parts:', filePathParts); // Decode any already-encoded segments first
console.log('[Gitea Proxy] File path (joined):', filePath); const decodedSegments = filePathParts.map(segment => {
console.log('[Gitea Proxy] Encoded file path:', encodedFilePath); try {
return decodeURIComponent(segment);
// Reconstruct: projects/{encodedProjectPath}/repository/files/{encodedFilePath}/raw } catch {
// Rebuild the path up to 'files', then add encoded file path and 'raw' return segment;
const beforeFiles = `projects/${encodedProjectPath}/repository/files`; }
processedPath = `${beforeFiles}/${encodedFilePath}/${parts[parts.length - 1]}`; });
} else {
// Not a file path, just re-encode the project path // Join with slashes to get the actual file path
processedPath = `projects/${encodedProjectPath}${parts.length > 3 ? '/' + parts.slice(3).join('/') : ''}`; // GitLab API accepts file paths with actual slashes / in the URL path
// Only encode individual segments if they contain special characters, but keep slashes as /
const filePath = decodedSegments
.map(segment => {
// Only encode if segment contains characters that need encoding (but not slashes)
const needsEncoding = /[^a-zA-Z0-9._/-]/.test(segment);
return needsEncoding ? encodeURIComponent(segment) : segment;
})
.join('/'); // Use actual slashes, NOT %2F
// Reconstruct: projects/{encodedProjectPath}/repository/files/{filePath}/raw
// Project path uses %2F (required), file path uses actual / (no %2F)
const beforeFiles = `projects/${encodedProjectPath}/repository/files`;
const lastPart = remainingParts[remainingParts.length - 1]; // 'raw'
processedPath = `${beforeFiles}/${filePath}/${lastPart}`;
} else {
// Not a file path, just reconstruct with encoded project path
processedPath = `projects/${encodedProjectPath}${remainingParts.length > 0 ? '/' + remainingParts.join('/') : ''}`;
}
} else if (parts.length >= 3) {
// Project path is split: parts[1] = owner, parts[2] = repo
const projectPath = `${parts[1]}/${parts[2]}`;
encodedProjectPath = encodeURIComponent(projectPath); // Creates owner%2Frepo
// Remaining parts start from index 3
const remainingParts = parts.slice(3);
// Check if this is a file path request
const filesIndex = remainingParts.indexOf('files');
if (filesIndex !== -1 && filesIndex < remainingParts.length - 1) {
// This is a file path: projects/{owner}/{repo}/repository/files/{file_path}/raw
// Extract file path segments (everything between 'files' and 'raw')
const filePathParts = remainingParts.slice(filesIndex + 1, remainingParts.length - 1);
// Decode any already-encoded segments first
const decodedSegments = filePathParts.map(segment => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
});
// Join with slashes to get the actual file path
// GitLab API accepts file paths with actual slashes / in the URL path
// Only encode individual segments if they contain special characters, but keep slashes as /
const filePath = decodedSegments
.map(segment => {
// Only encode if segment contains characters that need encoding (but not slashes)
const needsEncoding = /[^a-zA-Z0-9._/-]/.test(segment);
return needsEncoding ? encodeURIComponent(segment) : segment;
})
.join('/'); // Use actual slashes, NOT %2F
// Reconstruct: projects/{encodedProjectPath}/repository/files/{filePath}/raw
// Project path uses %2F (required), file path uses actual / (no %2F)
const beforeFiles = `projects/${encodedProjectPath}/repository/files`;
const lastPart = remainingParts[remainingParts.length - 1]; // 'raw'
processedPath = `${beforeFiles}/${filePath}/${lastPart}`;
} else {
// Not a file path, just reconstruct with encoded project path
processedPath = `projects/${encodedProjectPath}${remainingParts.length > 0 ? '/' + remainingParts.join('/') : ''}`;
}
} }
} }
} }
@ -79,9 +264,12 @@ function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearc
} }
const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''; const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
// Construct the full URL manually to preserve encoding // Construct the full URL as a string
// Note: We construct as string because new URL() with pathname assignment would decode %2F // We must construct as string to preserve %2F encoding
return `${cleanBaseUrl}${cleanApiPath}${queryString}`; // Using URL constructor would decode %2F, which we don't want
const fullUrl = `${cleanBaseUrl}${cleanApiPath}${queryString}`;
return fullUrl;
} }
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
@ -99,10 +287,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
const targetUrl = buildTargetUrl(baseUrl, apiPath, url.searchParams); const targetUrl = buildTargetUrl(baseUrl, apiPath, url.searchParams);
// Debug logging (remove in production if needed) // Use fetch with the URL string directly
console.log('[Gitea Proxy] Original path:', apiPath); // fetch() will handle the URL correctly, preserving %2F encoding
console.log('[Gitea Proxy] Target URL:', targetUrl);
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -114,6 +300,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
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();
// Log error responses for debugging // Log error responses for debugging
if (!response.ok) { if (!response.ok) {
console.error('[Gitea Proxy] Error response:', response.status, response.statusText); console.error('[Gitea Proxy] Error response:', response.status, response.statusText);

32
src/routes/repos/+page.svelte

@ -14,7 +14,8 @@
import { sessionManager } from '../../lib/services/auth/session-manager.js'; import { sessionManager } from '../../lib/services/auth/session-manager.js';
let repos = $state<NostrEvent[]>([]); let repos = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(false); // Start as false - will be set to true only if no cache
let cacheLoaded = $state(false); // Track if cache has been checked
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null }); let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null); let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
@ -37,16 +38,30 @@
} }
} }
// Load cached repos immediately when component initializes (before mount)
$effect(() => {
if (!cacheLoaded && typeof window !== 'undefined') {
cacheLoaded = true;
loadCachedRepos();
}
});
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
await loadCachedRepos(); // Only load from network if we don't have cached repos
await loadRepos(); if (repos.length === 0) {
await loadRepos();
} else {
// Load fresh data in background while showing cached data
loadRepos();
}
}); });
async function loadCachedRepos() { async function loadCachedRepos() {
try { try {
// Load cached repos (within 1 hour - optimized for slow connections) // Load cached repos (within 1 hour - optimized for slow connections)
const cachedRepos = await getRecentCachedEvents([KIND.REPO_ANNOUNCEMENT], 60 * 60 * 1000, 100); // Use longer maxAge to show cached data even if it's older
const cachedRepos = await getRecentCachedEvents([KIND.REPO_ANNOUNCEMENT], 24 * 60 * 60 * 1000, 100);
if (cachedRepos.length > 0) { if (cachedRepos.length > 0) {
// For parameterized replaceable events, get the newest version of each (by pubkey + d tag) // For parameterized replaceable events, get the newest version of each (by pubkey + d tag)
@ -65,10 +80,17 @@
if (sortedRepos.length > 0) { if (sortedRepos.length > 0) {
repos = sortedRepos; repos = sortedRepos;
loading = false; // Show cached data immediately loading = false; // Show cached data immediately
} else {
// No valid cached repos, show loading
loading = true;
} }
} else {
// No cached repos, show loading
loading = true;
} }
} catch (error) { } catch (error) {
// Cache error (non-critical) // Cache error (non-critical) - show loading
loading = true;
} }
} }

148
src/routes/repos/[naddr]/+page.svelte

@ -163,6 +163,9 @@
repoEvent = matchingEvent; repoEvent = matchingEvent;
loading = false; // Show cached data immediately loading = false; // Show cached data immediately
// Load maintainer profiles immediately from cache
loadMaintainerProfiles();
// Load issues and documentation in background (but not git repo - wait for tab click) // Load issues and documentation in background (but not git repo - wait for tab click)
Promise.all([ Promise.all([
loadIssues(), loadIssues(),
@ -246,6 +249,9 @@
// Don't fetch git repo here - wait until user clicks on repository tab // Don't fetch git repo here - wait until user clicks on repository tab
// This prevents rate limiting from GitHub/GitLab/Gitea // This prevents rate limiting from GitHub/GitLab/Gitea
// Load maintainer profiles immediately from cache
loadMaintainerProfiles();
// Step 2: Batch load all related data in parallel (only if not already loaded from cache) // Step 2: Batch load all related data in parallel (only if not already loaded from cache)
if (issues.length === 0 && documentationEvents.size === 0) { if (issues.length === 0 && documentationEvents.size === 0) {
await Promise.all([ await Promise.all([
@ -570,6 +576,30 @@
} }
} }
// Load maintainer profiles immediately from cache
async function loadMaintainerProfiles() {
if (!repoEvent) return;
try {
const pubkeys = new Set<string>();
// Add repo owner and maintainers
pubkeys.add(repoEvent.pubkey);
const maintainers = getMaintainers();
maintainers.forEach(m => pubkeys.add(m));
if (pubkeys.size === 0) return;
const uniquePubkeys = Array.from(pubkeys);
// Fetch profiles (will use cache first, then fetch from network if needed)
const relays = relayManager.getProfileReadRelays();
await fetchProfiles(uniquePubkeys, relays);
} catch (error) {
// Failed to load profiles - non-critical
}
}
async function loadAllProfiles() { async function loadAllProfiles() {
if (issues.length === 0) return; if (issues.length === 0) return;
@ -843,7 +873,123 @@
html = marked.parse(content) as string; html = marked.parse(content) as string;
} }
// Sanitize the HTML output // Sanitize the HTML output
return sanitizeHtml(html); html = sanitizeHtml(html);
// Process relative image and link paths for GitLab repos
if (gitRepo && gitRepo.url) {
html = processRelativePaths(html, gitRepo);
}
return html;
}
function processRelativePaths(html: string, repo: GitRepoInfo): string {
// Extract Git URL info
if (!repoEvent) return html;
const gitUrls = extractGitUrls(repoEvent);
if (gitUrls.length === 0) return html;
const gitUrl = gitUrls[0];
const urlObj = new URL(gitUrl);
const host = urlObj.origin;
const pathParts = urlObj.pathname.split('/').filter(p => p);
// Determine platform and extract owner/repo
let owner: string;
let repoName: string;
let baseUrl: string;
let defaultBranch: string;
if (host.includes('github.com')) {
// GitHub: /owner/repo.git or /owner/repo
if (pathParts.length >= 2) {
owner = pathParts[0];
repoName = pathParts[1].replace(/\.git$/, '');
baseUrl = 'https://api.github.com';
defaultBranch = repo.defaultBranch || 'main';
} else {
return html; // Can't parse, return as-is
}
} else if (host.includes('gitlab.com') || host.includes('gitea.com') || host.includes('codeberg.org')) {
// GitLab/Gitea: /owner/repo.git or /owner/repo
if (pathParts.length >= 2) {
owner = pathParts[0];
repoName = pathParts[1].replace(/\.git$/, '');
if (host.includes('gitlab.com')) {
baseUrl = 'https://gitlab.com/api/v4';
} else if (host.includes('gitea.com')) {
baseUrl = `${host}/api/v1`;
} else if (host.includes('codeberg.org')) {
baseUrl = 'https://codeberg.org/api/v1';
} else {
baseUrl = `${host}/api/v1`;
}
defaultBranch = repo.defaultBranch || 'master';
} else {
return html; // Can't parse, return as-is
}
} else {
return html; // Unknown platform, return as-is
}
const projectPath = `${owner}/${repoName}`;
const encodedPath = encodeURIComponent(projectPath);
// Process img src attributes
html = html.replace(/<img([^>]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => {
// Skip if already absolute URL
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('#')) {
return match;
}
// Convert relative path to GitLab raw file URL
const filePath = src.startsWith('/') ? src.slice(1) : src;
let rawUrl: string;
if (host.includes('github.com')) {
rawUrl = `https://raw.githubusercontent.com/${projectPath}/${defaultBranch}/${filePath}`;
} else if (host.includes('gitlab.com')) {
rawUrl = `/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(filePath)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`;
} else {
// Gitea/Codeberg
rawUrl = `/api/gitea-proxy/repos/${encodedPath}/raw/${encodeURIComponent(filePath)}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`;
}
return `<img${before} src="${rawUrl}"${after}>`;
});
// Process a href attributes (for relative links to files)
html = html.replace(/<a([^>]*)\shref=["']([^"']+)["']([^>]*)>/gi, (match, before, href, after) => {
// Skip if already absolute URL, anchor, or mailto
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#') || href.startsWith('mailto:')) {
return match;
}
// Only convert relative paths that look like file paths (have extensions or are in common directories)
const isFile = /\.(md|txt|adoc|rst|png|jpg|jpeg|gif|svg|pdf|zip|tar|gz)$/i.test(href) ||
/^(resources|assets|images|img|docs|files)\//i.test(href);
if (!isFile) {
return match; // Probably a relative anchor or page link, leave as-is
}
// Convert relative path to GitLab raw file URL
const filePath = href.startsWith('/') ? href.slice(1) : href;
let rawUrl: string;
if (host.includes('github.com')) {
rawUrl = `https://raw.githubusercontent.com/${projectPath}/${defaultBranch}/${filePath}`;
} else if (host.includes('gitlab.com')) {
rawUrl = `/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(filePath)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`;
} else {
// Gitea/Codeberg
rawUrl = `/api/gitea-proxy/repos/${encodedPath}/raw/${encodeURIComponent(filePath)}?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`;
}
return `<a${before} href="${rawUrl}"${after}>`;
});
return html;
} }
function getFileTree(files: GitFile[]): any { function getFileTree(files: GitFile[]): any {

1
static/changelog.yaml

@ -1,6 +1,7 @@
versions: versions:
'0.3.2': '0.3.2':
- 'Expanded /repos to handle GitLab, Gitea, and OneDev repositories' - 'Expanded /repos to handle GitLab, Gitea, and OneDev repositories'
- 'Improved code block highlighting for pure black background'
'0.3.1': '0.3.1':
- 'Media attachments rendering in all feeds and views' - 'Media attachments rendering in all feeds and views'
- 'NIP-92/NIP-94 image tags support' - 'NIP-92/NIP-94 image tags support'

4
static/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.3.2", "version": "0.3.2",
"buildTime": "2026-02-14T07:34:30.130Z", "buildTime": "2026-02-14T08:45:25.090Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1771054470130 "timestamp": 1771058725091
} }
Loading…
Cancel
Save