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. 257
      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 @@ @@ -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 */
@tailwind base;
/* stylelint-disable-next-line at-rule-no-unknown */

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

@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
import { fetchGitHubApi } from '../../services/github-api.js';
// @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
import Icon from '../ui/Icon.svelte';
interface Props {
@ -34,13 +33,36 @@ @@ -34,13 +33,36 @@
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i === parts.length - 1) {
// File
current[part] = file;
const isLast = i === parts.length - 1;
const isDirectory = file.type === 'dir';
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 {
// Directory
if (!current[part] || current[part].path) {
// Intermediate path segment - must be a directory
if (!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];
}
@ -106,7 +128,8 @@ @@ -106,7 +128,8 @@
? 'https://gitlab.com/api/v4'
: `${baseHost}/api/v4`;
// 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')) {
// OneDev API: similar to Gitea, uses /api/v1/repos/{owner}/{repo}/contents/{path}
@ -147,15 +170,30 @@ @@ -147,15 +170,30 @@
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 (data.content) {
// GitHub format
fileContent = atob(data.content.replace(/\s/g, ''));
if (url.includes('github.com')) {
// GitHub API returns JSON with base64 encoded content
const data = await response.json();
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 {
// GitLab/Gitea format (raw text)
fileContent = typeof data === 'string' ? data : await response.text();
// Default to text for raw file endpoints
fileContent = await response.text();
}
} catch (error) {
console.error('Error fetching file content:', error);
@ -247,8 +285,8 @@ @@ -247,8 +285,8 @@
'dockerfile': 'dockerfile', 'makefile': 'makefile', 'cmake': 'cmake',
'gradle': 'gradle', 'maven': 'xml', 'pom': 'xml',
'toml': 'toml', 'ini': 'ini', 'conf': 'ini', 'config': 'ini',
'properties': 'properties', 'env': 'bash', 'gitignore': 'gitignore',
'dockerignore': 'gitignore', 'editorconfig': 'ini', 'eslintrc': 'json', 'prettierrc': 'json'
'properties': 'properties', 'env': 'bash', 'gitignore': 'plaintext',
'dockerignore': 'plaintext', 'editorconfig': 'ini', 'eslintrc': 'json', 'prettierrc': 'json'
};
return languageMap[ext] || 'plaintext';
}
@ -286,8 +324,22 @@ @@ -286,8 +324,22 @@
$effect(() => {
if (fileContent && selectedFile && isCodeFile(selectedFile) && codeRef) {
const language = getLanguageFromExtension(selectedFile);
codeRef.innerHTML = hljs.highlight(fileContent, { language }).value;
codeRef.className = `language-${language}`;
try {
// 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>
@ -534,7 +586,8 @@ @@ -534,7 +586,8 @@
.file-explorer {
display: flex;
gap: 1rem;
height: 600px;
height: 1000px;
min-height: 800px;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
@ -767,8 +820,8 @@ @@ -767,8 +820,8 @@
.file-content-code {
margin: 0;
background: #1e1e1e !important; /* VS Code dark background, same as JSON preview */
border: 1px solid #3e3e3e;
background: #000000 !important; /* Pure black background */
border: 1px solid #333333;
border-radius: 4px;
padding: 1rem;
overflow-x: auto;
@ -777,60 +830,15 @@ @@ -777,60 +830,15 @@
}
:global(.dark) .file-content-code {
background: #1e1e1e !important;
border-color: #3e3e3e;
background: #000000 !important; /* Pure black background */
border-color: #333333;
}
.file-content-code code {
display: block;
overflow-x: auto;
padding: 0;
background: transparent !important;
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;
/* Theme colors are defined in highlight-theme.css */
}
.file-image-container {
@ -919,7 +927,8 @@ @@ -919,7 +927,8 @@
.file-explorer {
flex-direction: column;
height: auto;
max-height: 800px;
min-height: 800px;
max-height: 1200px;
}
.file-tree-panel {

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

@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
import Icon from '../ui/Icon.svelte';
// @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
interface Props {
open?: boolean;
@ -13,6 +12,7 @@ @@ -13,6 +12,7 @@
let { open = $bindable(false), event = $bindable(null) }: Props = $props();
let jsonText = $derived(event ? JSON.stringify(event, null, 2) : '');
let copied = $state(false);
let wordWrap = $state(true); // Default to word-wrap enabled
let jsonPreviewRef: HTMLElement | null = $state(null);
// Highlight JSON when it changes
@ -72,13 +72,25 @@ @@ -72,13 +72,25 @@
<div class="modal-content">
<div class="modal-header">
<h2>Event JSON</h2>
<button onclick={close} class="close-button" aria-label="Close">
<Icon name="x" size={20} />
</button>
<div class="header-actions">
<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 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 class="modal-footer">
@ -162,6 +174,12 @@ @@ -162,6 +174,12 @@
flex-shrink: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 640px) {
.modal-header {
padding: 0.75rem;
@ -186,6 +204,47 @@ @@ -186,6 +204,47 @@
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 {
background: none;
border: none;
@ -211,24 +270,50 @@ @@ -211,24 +270,50 @@
}
.json-preview {
background: #1e1e1e !important; /* VS Code dark background, same as code blocks */
border: 1px solid #3e3e3e;
background: #000000 !important; /* Pure black background - uses global highlight theme */
border: 1px solid #333333;
border-radius: 4px;
padding: 1rem;
margin: 0;
overflow-x: auto;
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 {
display: block;
overflow-x: auto;
padding: 0;
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-size: 0.875rem;
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) {

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

@ -339,6 +339,13 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr @@ -339,6 +339,13 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
if (branchesResponse.ok) {
const data = await branchesResponse.json();
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) {
@ -363,17 +370,83 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr @@ -363,17 +370,83 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
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[] = [];
try {
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,
type: item.type === 'tree' ? 'dir' : 'file',
size: item.size
}));
} catch {
let pageToken: string | null = null;
let hasMore = true;
let pageCount = 0;
const maxPages = 50; // Safety limit to prevent infinite loops
while (hasMore && pageCount < maxPages) {
pageCount++;
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
}
@ -381,10 +454,15 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr @@ -381,10 +454,15 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
// First try root directory (most common case)
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
const defaultBranch = repoData.default_branch || 'master';
for (const readmeFile of readmeFiles) {
try {
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');
const readmeUrl = `/api/gitea-proxy/projects/${encodedPath}/repository/files/${encodeURIComponent(readmeFile)}/raw?baseUrl=${encodeURIComponent(baseUrl)}&ref=${defaultBranch}`;
const fileData = await fetch(readmeUrl).then(r => {
if (!r.ok) {
throw new Error('Not found');
}
return r.text();
});
readme = {
@ -393,7 +471,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr @@ -393,7 +471,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'
};
break; // Found a README, stop searching
} catch {
} catch (error) {
continue; // Try next file
}
}

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

@ -5,7 +5,8 @@ import type { RequestHandler } from '@sveltejs/kit'; @@ -5,7 +5,8 @@ import type { RequestHandler } from '@sveltejs/kit';
* 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
* - 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
*/
@ -29,40 +30,224 @@ function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearc @@ -29,40 +30,224 @@ function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearc
// 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
// Handle GitLab raw file requests - convert from API v4 format to raw file URL format
// 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;
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);
if (parts.length >= 2) {
// Determine if project path is already encoded (contains %2F) or split across parts
let encodedProjectPath: string;
// 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);
if (parts[1].includes('%2F') || parts[1].includes('%2f')) {
// Project path is already encoded in parts[1] (e.g., "Pleb5%2Fjumble-fork-test")
encodedProjectPath = 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 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('/') : ''}`;
}
} 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
// 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);
// Remaining parts start from index 3
const remainingParts = parts.slice(3);
// 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('/') : ''}`;
// 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 @@ -79,9 +264,12 @@ function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearc
}
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}`;
// Construct the full URL as a string
// We must construct as string to preserve %2F encoding
// 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 }) => {
@ -99,10 +287,8 @@ 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);
// Debug logging (remove in production if needed)
console.log('[Gitea Proxy] Original path:', apiPath);
console.log('[Gitea Proxy] Target URL:', targetUrl);
// Use fetch with the URL string directly
// fetch() will handle the URL correctly, preserving %2F encoding
const response = await fetch(targetUrl, {
method: 'GET',
headers: {
@ -114,6 +300,7 @@ export const GET: RequestHandler = async ({ params, url }) => { @@ -114,6 +300,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
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);

32
src/routes/repos/+page.svelte

@ -14,7 +14,8 @@ @@ -14,7 +14,8 @@
import { sessionManager } from '../../lib/services/auth/session-manager.js';
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 searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
@ -37,16 +38,30 @@ @@ -37,16 +38,30 @@
}
}
// Load cached repos immediately when component initializes (before mount)
$effect(() => {
if (!cacheLoaded && typeof window !== 'undefined') {
cacheLoaded = true;
loadCachedRepos();
}
});
onMount(async () => {
await nostrClient.initialize();
await loadCachedRepos();
await loadRepos();
// Only load from network if we don't have cached repos
if (repos.length === 0) {
await loadRepos();
} else {
// Load fresh data in background while showing cached data
loadRepos();
}
});
async function loadCachedRepos() {
try {
// 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) {
// For parameterized replaceable events, get the newest version of each (by pubkey + d tag)
@ -65,10 +80,17 @@ @@ -65,10 +80,17 @@
if (sortedRepos.length > 0) {
repos = sortedRepos;
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) {
// Cache error (non-critical)
// Cache error (non-critical) - show loading
loading = true;
}
}

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

@ -163,6 +163,9 @@ @@ -163,6 +163,9 @@
repoEvent = matchingEvent;
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)
Promise.all([
loadIssues(),
@ -246,6 +249,9 @@ @@ -246,6 +249,9 @@
// Don't fetch git repo here - wait until user clicks on repository tab
// 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)
if (issues.length === 0 && documentationEvents.size === 0) {
await Promise.all([
@ -570,6 +576,30 @@ @@ -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() {
if (issues.length === 0) return;
@ -843,7 +873,123 @@ @@ -843,7 +873,123 @@
html = marked.parse(content) as string;
}
// 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 {

1
static/changelog.yaml

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

4
static/healthz.json

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