Browse Source

implement core GRASP managmeent

handle ssh clones
fix issues fetch and status publishing
master
Silberengel 4 weeks ago
parent
commit
770a77912f
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 111
      src/lib/components/EventMenu.svelte
  4. 19
      src/lib/components/profile/EditProfileEventsPanel.svelte
  5. 19
      src/lib/components/profile/ProfileEventsPanel.svelte
  6. 198
      src/lib/components/write/CreateEventForm.svelte
  7. 606
      src/lib/services/content/git-repo-fetcher.ts
  8. 357
      src/lib/services/git/git-protocol-client.ts
  9. 16
      src/lib/services/nostr/config.ts
  10. 4
      src/lib/services/nostr/relay-manager.ts
  11. 2
      src/lib/types/kind-lookup.ts
  12. 23
      src/lib/types/kind-metadata.ts
  13. 168
      src/routes/api/gitea-proxy/[...path]/+server.ts
  14. 6
      src/routes/relay/+page.svelte
  15. 141
      src/routes/repos/+page.svelte
  16. 83
      src/routes/repos/[naddr]/+page.svelte
  17. 3
      static/changelog.yaml
  18. 6
      static/healthz.json

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "aitherboard",
"version": "0.3.2",
"version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aitherboard",
"version": "0.3.2",
"version": "0.3.3",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "aitherboard",
"version": "0.3.2",
"version": "0.3.3",
"type": "module",
"author": "silberengel@gitcitadel.com",
"description": "A decentralized messageboard built on the Nostr protocol.",

111
src/lib/components/EventMenu.svelte

@ -27,6 +27,7 @@ @@ -27,6 +27,7 @@
import { goto } from '$app/navigation';
import Icon from './ui/Icon.svelte';
import { getEventLink } from '../services/event-links.js';
import { isGraspUrl } from '../services/content/git-repo-fetcher.js';
interface Props {
event: NostrEvent;
@ -276,13 +277,111 @@ @@ -276,13 +277,111 @@
goto(getEventLink(event));
}
function cloneEvent() {
async function editGraspList() {
const currentPubkey = sessionManager.getCurrentPubkey();
if (!currentPubkey) {
closeMenu();
return;
}
try {
// Fetch user's grasp list (kind 10317)
const graspListEvents = await nostrClient.fetchEvents(
[{ kinds: [10317], authors: [currentPubkey], limit: 1 }],
relayManager.getProfileReadRelays(),
{ useCache: 'cache-first', cacheResults: true }
);
let graspListData;
if (graspListEvents.length > 0) {
// Edit existing grasp list
const existingEvent = graspListEvents[0];
graspListData = {
kind: 10317,
content: existingEvent.content || '',
tags: existingEvent.tags || [],
isClone: false
};
} else {
// Create new grasp list with defaults
const { config } = await import('../services/nostr/config.js');
const graspRelaysForTags = config.graspRelays.filter(r => !r.includes('thecitadel'));
graspListData = {
kind: 10317,
content: '',
tags: graspRelaysForTags.map(server => ['g', server]),
isClone: false
};
}
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(graspListData));
closeMenu();
goto('/write');
} catch (error) {
console.error('Failed to load grasp list:', error);
closeMenu();
}
}
async function cloneEvent() {
let tags = event.tags || [];
// For repo announcements, add GRASP clone if missing
if (event.kind === 30617 || event.kind === KIND.REPO_ANNOUNCEMENT) {
const hasGraspClone = tags.some(t =>
t[0] === 'clone' && t[1] && isGraspUrl(t[1])
);
if (!hasGraspClone) {
// Get user's grasp server preference (kind 10317) or use default
const currentPubkey = sessionManager.getCurrentPubkey();
if (currentPubkey) {
const dTag = tags.find(t => t[0] === 'd')?.[1] || '';
if (dTag) {
try {
const npub = nip19.npubEncode(currentPubkey);
// Try to fetch user's grasp server preference (kind 10317)
let graspServer = '';
try {
const graspListEvents = await nostrClient.fetchEvents(
[{ kinds: [10317], authors: [currentPubkey], limit: 1 }],
relayManager.getProfileReadRelays(),
{ useCache: 'cache-first', cacheResults: true }
);
if (graspListEvents.length > 0) {
const gTag = graspListEvents[0].tags.find(t => t[0] === 'g');
if (gTag && gTag[1]) {
graspServer = gTag[1]; // Use first grasp server
}
}
} catch (error) {
console.warn('Failed to fetch grasp server preference:', error);
}
// Fallback to default if no preference found
if (!graspServer) {
graspServer = 'wss://relay.ngit.dev'; // Default
}
// Convert ws:// to https:// if needed
const httpsGraspServer = graspServer.replace(/^wss?:\/\//, 'https://').replace(/\/$/, '');
const graspUrl = `${httpsGraspServer}/${npub}/${dTag}.git`;
tags = [...tags, ['clone', graspUrl]];
} catch (error) {
console.warn('Failed to create GRASP clone URL:', error);
}
}
}
}
}
// Store event data in sessionStorage for the write page to pick up
// Ensure content is preserved (important for kind 0 which has stringified JSON)
const cloneData = {
kind: event.kind,
content: event.content || '', // Explicitly preserve content, even if empty string
tags: event.tags || [],
tags: tags,
isClone: true
};
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(cloneData));
@ -300,7 +399,7 @@ @@ -300,7 +399,7 @@
let allRelays = relayManager.getAllAvailableRelays();
// Add thread publish relays (includes thread-specific relays)
allRelays = [...allRelays, ...relayManager.getThreadPublishRelays()];
allRelays = [...allRelays, ...relayManager.getdocumentationPublishRelays()];
// Add file metadata publish relays (includes GIF relays)
allRelays = [...allRelays, ...relayManager.getFileMetadataPublishRelays()];
@ -509,6 +608,12 @@ @@ -509,6 +608,12 @@
<Icon name="edit" size={16} />
<span>Edit/Clone this event</span>
</button>
{#if (event.kind === 30617 || event.kind === KIND.REPO_ANNOUNCEMENT) && isLoggedIn}
<button class="menu-item" onclick={editGraspList}>
<Icon name="settings" size={16} />
<span>Edit User Grasp List</span>
</button>
{/if}
<button class="menu-item" onclick={broadcastEvent} disabled={broadcasting}>
<Icon name="radio" size={16} />
<span>{broadcasting ? 'Broadcasting...' : 'Broadcast event'}</span>

19
src/lib/components/profile/EditProfileEventsPanel.svelte

@ -14,21 +14,30 @@ @@ -14,21 +14,30 @@
let { isOpen, pubkey, onClose }: Props = $props();
const PROFILE_EVENT_KINDS = [
// Core profile
{ kind: 0, name: 'Metadata (Profile)' },
{ kind: 3, name: 'Contacts' },
{ kind: 30315, name: 'User Status' },
{ kind: 10133, name: 'Payment Addresses' },
// Relay lists
{ kind: 10002, name: 'Relay List' },
{ kind: 10012, name: 'Favorite Relays' },
{ kind: 10432, name: 'Local Relays' },
{ kind: 10006, name: 'Blocked Relays' },
// User lists
{ kind: 10001, name: 'Pin List' },
{ kind: 10003, name: 'Bookmarks' },
{ kind: 10895, name: 'RSS Feed' },
{ kind: 10000, name: 'Mute List' },
{ kind: 10015, name: 'Interest List' },
{ kind: 30000, name: 'Follow Set' },
// Status and info
{ kind: 30315, name: 'User Status' },
{ kind: 10133, name: 'Payment Addresses' },
// Emoji
{ kind: 10030, name: 'Emoji Set' },
{ kind: 30030, name: 'Emoji Pack' },
{ kind: 10000, name: 'Mute List' },
// Other
{ kind: 10895, name: 'RSS Feed' },
{ kind: 30008, name: 'Badges' },
{ kind: 30000, name: 'Follow Set' }
{ kind: 10317, name: 'User Grasp List' },
];
let eventMap = $state<Map<number, NostrEvent>>(new Map());

19
src/lib/components/profile/ProfileEventsPanel.svelte

@ -18,21 +18,30 @@ @@ -18,21 +18,30 @@
let { isOpen, pubkey, onClose }: Props = $props();
const PROFILE_EVENT_KINDS = [
// Core profile
{ kind: 0, name: 'Metadata (Profile)' },
{ kind: 3, name: 'Contacts' },
{ kind: 30315, name: 'User Status' },
{ kind: 10133, name: 'Payment Addresses' },
// Relay lists
{ kind: 10002, name: 'Relay List' },
{ kind: 10012, name: 'Favorite Relays' },
{ kind: 10432, name: 'Local Relays' },
{ kind: 10006, name: 'Blocked Relays' },
// User lists
{ kind: 10001, name: 'Pin List' },
{ kind: 10003, name: 'Bookmarks' },
{ kind: 10895, name: 'RSS Feed' },
{ kind: 10000, name: 'Mute List' },
{ kind: 10015, name: 'Interest List' },
{ kind: 30000, name: 'Follow Set' },
// Status and info
{ kind: 30315, name: 'User Status' },
{ kind: 10133, name: 'Payment Addresses' },
// Emoji
{ kind: 10030, name: 'Emoji Set' },
{ kind: 30030, name: 'Emoji Pack' },
{ kind: 10000, name: 'Mute List' },
// Other
{ kind: 10895, name: 'RSS Feed' },
{ kind: 30008, name: 'Badges' },
{ kind: 30000, name: 'Follow Set' }
{ kind: 10317, name: 'User Grasp List' },
];
let selectedKind = $state<number | null>(null);

198
src/lib/components/write/CreateEventForm.svelte

@ -20,6 +20,9 @@ @@ -20,6 +20,9 @@
import { autoExtractTags, ensureDTagForParameterizedReplaceable } from '../../services/auto-tagging.js';
import { isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
import Icon from '../ui/Icon.svelte';
import { config } from '../../services/nostr/config.js';
import { nip19 } from 'nostr-tools';
import { nostrClient } from '../../services/nostr/nostr-client.js';
// @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
@ -67,6 +70,7 @@ @@ -67,6 +70,7 @@
let tags = $state<string[][]>(getInitialTags());
let publishing = $state(false);
let showAdvancedEditor = $state(false);
let hasGraspList = $state<boolean | null>(null); // null = not checked yet
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
@ -103,6 +107,32 @@ @@ -103,6 +107,32 @@
}
});
// Check if user has grasp list when creating repo announcement
$effect(() => {
if (effectiveKind === 30617 || effectiveKind === KIND.REPO_ANNOUNCEMENT) {
const currentPubkey = sessionManager.getCurrentPubkey();
if (currentPubkey) {
(async () => {
try {
const graspListEvents = await nostrClient.fetchEvents(
[{ kinds: [10317], authors: [currentPubkey], limit: 1 }],
relayManager.getProfileReadRelays(),
{ useCache: 'cache-first', cacheResults: true }
);
hasGraspList = graspListEvents.length > 0;
} catch (error) {
console.warn('Failed to check for grasp list:', error);
hasGraspList = false; // Assume missing on error
}
})();
} else {
hasGraspList = false;
}
} else {
hasGraspList = null; // Reset when not creating repo announcement
}
});
// Restore draft from IndexedDB if no initial event
$effect(() => {
if (typeof window === 'undefined' || initialEvent) return;
@ -270,7 +300,7 @@ @@ -270,7 +300,7 @@
}
}
if (shouldIncludeClientTag()) {
if (shouldIncludeClientTag() && !allTags.some(t => t[0] === 'client')) {
allTags.push(['client', 'aitherboard']);
}
@ -345,7 +375,7 @@ @@ -345,7 +375,7 @@
}
}
if (shouldIncludeClientTag()) {
if (shouldIncludeClientTag() && !allTags.some(t => t[0] === 'client')) {
allTags.push(['client', 'aitherboard']);
}
@ -371,16 +401,61 @@ @@ -371,16 +401,61 @@
});
}
const relays = relayManager.getPublishRelays(
let relays = relayManager.getPublishRelays(
relayManager.getProfileReadRelays(),
true
);
// For repo announcements, also add documentation and GRASP relays
if (effectiveKind === 30617 || effectiveKind === KIND.REPO_ANNOUNCEMENT) {
relays = [...new Set([...relays, ...config.documentationPublishRelays, ...config.graspRelays])];
}
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
// For repo announcements, check if we need to create a grasp list
if (effectiveKind === 30617 || effectiveKind === KIND.REPO_ANNOUNCEMENT) {
try {
const currentPubkey = session.pubkey;
const graspListEvents = await nostrClient.fetchEvents(
[{ kinds: [10317], authors: [currentPubkey], limit: 1 }],
relayManager.getProfileReadRelays(),
{ useCache: 'cache-first', cacheResults: true }
);
if (graspListEvents.length === 0) {
// User doesn't have a grasp list, create one
// Only include actual GRASP relays in the g tags (not documentation relays)
// Filter out thecitadel since it's not a GRASP server
const graspRelaysForTags = config.graspRelays.filter(r => !r.includes('thecitadel'));
const graspListEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: 10317,
pubkey: currentPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: graspRelaysForTags.map(server => ['g', server]), // Only GRASP relays in g tags
content: ''
};
const signedGraspListEvent = await session.signer(graspListEventTemplate);
await cacheEvent(signedGraspListEvent);
// Publish grasp list to documentation and GRASP relays
// (documentation relay accepts these kinds but isn't a GRASP relay, so not in g tags)
const allGraspRelays = [...new Set([...config.documentationPublishRelays, ...config.graspRelays])];
await signAndPublish(graspListEventTemplate, allGraspRelays);
console.log('Auto-created and published user grasp list (kind 10317)');
}
} catch (error) {
console.warn('Failed to check/create grasp list:', error);
// Non-critical, continue
}
}
content = '';
tags = [];
uploadedFiles = [];
@ -389,7 +464,28 @@ @@ -389,7 +464,28 @@
}
await deleteDraft(DRAFT_ID);
setTimeout(() => {
// Store event in sessionStorage so the event page can use it without re-fetching
// For repository announcements (kind 30617), redirect to repo page
if (effectiveKind === 30617 || effectiveKind === KIND.REPO_ANNOUNCEMENT) {
const dTag = signedEvent.tags.find(t => t[0] === 'd')?.[1] || '';
if (dTag) {
try {
// Only include documentation relay as relay hint (keeps naddr shorter)
// Both events are published to documentation and GRASP relays, but we only hint at documentation relay
const relayHints = config.documentationPublishRelays;
const naddr = nip19.naddrEncode({
kind: signedEvent.kind,
pubkey: signedEvent.pubkey,
identifier: dTag,
relays: relayHints
});
goto(`/repos/${naddr}`);
return;
} catch (error) {
console.warn('Failed to encode naddr, falling back to event page:', error);
}
}
}
// Default: redirect to event page
sessionStorage.setItem('aitherboard_preloadedEvent', JSON.stringify(signedEvent));
goto(`/event/${signedEvent.id}`);
}, 5000);
@ -512,7 +608,7 @@ @@ -512,7 +608,7 @@
}
}
if (shouldIncludeClientTag()) {
if (shouldIncludeClientTag() && !previewTags.some(t => t[0] === 'client')) {
previewTags.push(['client', 'aitherboard']);
}
@ -689,6 +785,34 @@ @@ -689,6 +785,34 @@
</div>
<div class="form-actions">
{#if (effectiveKind === 30617 || effectiveKind === KIND.REPO_ANNOUNCEMENT) && hasGraspList === false}
<div class="grasp-list-notice">
<Icon name="info" size={16} />
<div class="notice-content">
<p class="notice-text">
A User Grasp List (kind 10317) will be created automatically on your behalf.
</p>
<p class="notice-relays">
Events will be published to:
</p>
<ul class="relay-list">
{#each [...config.documentationPublishRelays, ...config.graspRelays] as relay}
<li>
{relay}
{#if config.documentationPublishRelays.includes(relay)}
<span class="relay-note">(documentation relay, accepts these kinds but not a GRASP relay)</span>
{:else}
<span class="relay-note">(GRASP relay)</span>
{/if}
</li>
{/each}
</ul>
<p class="notice-note">
Note: Only GRASP relays will be included in the g tags of the kind 10317 event.
</p>
</div>
</div>
{/if}
<button
class="publish-button"
onclick={publish}
@ -1344,8 +1468,8 @@ @@ -1344,8 +1468,8 @@
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
flex-direction: column;
gap: 1rem;
}
@media (max-width: 768px) {
@ -1821,4 +1945,64 @@ @@ -1821,4 +1945,64 @@
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.grasp-list-notice {
display: flex;
gap: 0.75rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .grasp-list-notice {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f1f5f9);
}
.notice-content {
flex: 1;
}
.notice-text {
margin: 0 0 0.5rem 0;
font-weight: 500;
}
.notice-relays {
margin: 0.5rem 0 0.25rem 0;
font-size: 0.875rem;
}
.relay-list {
margin: 0.25rem 0 0 0;
padding-left: 1.5rem;
font-size: 0.875rem;
list-style: disc;
}
.relay-note {
color: var(--fog-text-light, #52667a);
font-style: italic;
margin-left: 0.5rem;
font-size: 0.8125rem;
}
:global(.dark) .relay-note {
color: var(--fog-dark-text-light, #a8b8d0);
}
.notice-note {
margin: 0.75rem 0 0 0;
font-size: 0.8125rem;
color: var(--fog-text-light, #52667a);
font-style: italic;
}
:global(.dark) .notice-note {
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

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

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
*/
import { fetchGitHubApi } from '../github-api.js';
import { fetchCommitTree, fetchGitObject, parseGitBlob, parseGitCommit } from '../git/git-protocol-client.js';
export interface GitRepoInfo {
name: string;
@ -49,7 +50,7 @@ export interface GitFile { @@ -49,7 +50,7 @@ export interface GitFile {
* Check if a URL is a GRASP (Git Repository Access via Secure Protocol) URL
* GRASP URLs contain npub (Nostr public key) in the path: https://host/npub.../repo.git
*/
function isGraspUrl(url: string): boolean {
export function isGraspUrl(url: string): boolean {
// GRASP URLs have npub (starts with npub1) in the path
return /\/npub1[a-z0-9]+/i.test(url);
}
@ -58,9 +59,20 @@ function isGraspUrl(url: string): boolean { @@ -58,9 +59,20 @@ function isGraspUrl(url: string): boolean {
* Parse git URL to extract platform, owner, and repo
*/
function parseGitUrl(url: string): { platform: string; owner: string; repo: string; baseUrl: string } | null {
// Skip GRASP URLs - they don't use standard git hosting APIs
// Handle GRASP URLs - they use Gitea-compatible API but with npub as owner
if (isGraspUrl(url)) {
return null;
// GRASP URLs: https://host/npub1.../repo.git
const graspMatch = url.match(/(https?:\/\/[^/]+)\/(npub1[a-z0-9]+)\/([^/]+?)(?:\.git)?\/?$/i);
if (graspMatch) {
const [, baseHost, npub, repo] = graspMatch;
return {
platform: 'grasp',
owner: npub, // npub is used as the owner identifier
repo: repo.replace(/\.git$/, ''),
baseUrl: `${baseHost}/api/v1` // GRASP servers use Gitea-compatible API
};
}
return null; // Invalid GRASP URL format
}
// GitHub
@ -739,6 +751,577 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro @@ -739,6 +751,577 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro
}
}
/**
* Parse git info/refs response to extract branches and default branch
* Git protocol uses length-prefixed lines: 4 hex chars = length, then data
*/
function parseInfoRefs(refsText: string): { branches: string[]; defaultBranch: string; headCommit: string } {
const branches: string[] = [];
let defaultBranch = 'master';
let headCommit = '';
// Parse the git protocol response format
// Format uses length-prefixed lines:
// 001e# service=git-upload-pack
// 0000014e<commit-hash> HEAD\0<capabilities>...
// 003f<commit-hash> refs/heads/master
// 0000
// First, extract symref from the HEAD line (it's in the capabilities)
const symrefMatch = refsText.match(/symref=HEAD:refs\/heads\/([^\s\0]+)/);
if (symrefMatch) {
defaultBranch = symrefMatch[1];
}
// Parse length-prefixed lines
// In git pkt-line format, the 4-byte hex length includes the 4-byte prefix itself
// So if length is 0x001e (30), the total line is 30 bytes: 4 bytes prefix + 26 bytes data
// Track branch commits as we parse
const branchCommits = new Map<string, string>();
let pos = 0;
let lineNumber = 0;
while (pos < refsText.length) {
lineNumber++;
// Read length prefix (4 hex characters)
if (pos + 4 > refsText.length) {
console.log(`[GRASP] Line ${lineNumber}: Not enough bytes for length prefix at pos ${pos}, remaining: ${refsText.length - pos}`);
break;
}
const lengthHex = refsText.substring(pos, pos + 4);
const totalLength = parseInt(lengthHex, 16);
if (totalLength === 0) {
// End marker (0000)
console.log(`[GRASP] Line ${lineNumber}: End marker (0000) at pos ${pos}`);
break;
}
// The length includes the 4-byte prefix, so data length is totalLength - 4
const dataLength = totalLength - 4;
if (dataLength < 0 || pos + totalLength > refsText.length) {
console.warn(`[GRASP] Line ${lineNumber}: Invalid length at pos ${pos}, totalLength=${totalLength}, dataLength=${dataLength}, remaining=${refsText.length - pos}`);
break;
}
// Skip the 4-byte prefix and read the data
pos += 4;
const line = refsText.substring(pos, pos + dataLength);
pos += dataLength;
console.log(`[GRASP] Line ${lineNumber}: pos=${pos - dataLength - 4}, length=${totalLength}, dataLength=${dataLength}, first 60 chars:`, line.substring(0, 60).replace(/\0/g, '\\0'));
// Skip service announcement
if (line.startsWith('# service=')) {
console.log('[GRASP] Skipping service announcement:', line.substring(0, 50));
continue;
}
// Log the line we're about to parse (for debugging)
if (line.length > 0) {
console.log('[GRASP] Parsing line (length:', line.length, '):', line.substring(0, 100).replace(/\0/g, '\\0'));
}
// Extract commit hash and ref name
// Format: <40-char-hex-hash> <ref-name>[\0<capabilities>]
// The hash is always 40 hex characters at the start
// After the hash, there's a space, then the ref name, then optionally a null byte and capabilities
// First, try to extract the 40-char hash from the start
const hashMatch = line.match(/^([0-9a-f]{40})/);
if (!hashMatch) {
// No hash found, skip this line
if (line.length > 0) {
console.warn('[GRASP] No hash found in line:', line.substring(0, 80));
}
continue;
}
const commitHash = hashMatch[1];
// Skip all-zero hashes (these are capability advertisements, not real refs)
if (commitHash === '0000000000000000000000000000000000000000') {
console.log('[GRASP] Skipping all-zero hash (capability advertisement)');
continue;
}
// Extract ref name: everything after the 40-char hash and a space, up to null byte or end
// The ref name starts at position 41 (after 40-char hash) and may have a space before it
let refName = '';
const afterHash = line.substring(40);
// Skip leading whitespace
let refStart = 0;
while (refStart < afterHash.length && (afterHash[refStart] === ' ' || afterHash[refStart] === '\t')) {
refStart++;
}
// Extract ref name up to null byte or end of line
const refPart = afterHash.substring(refStart);
const nullIndex = refPart.indexOf('\0');
if (nullIndex >= 0) {
refName = refPart.substring(0, nullIndex).trim();
} else {
refName = refPart.trim();
}
// Extract HEAD commit (from HEAD line)
if (refName === 'HEAD' && !headCommit) {
headCommit = commitHash;
console.log('[GRASP] Set headCommit from HEAD line:', headCommit);
}
// Extract branches
const branchMatch = refName.match(/^refs\/heads\/(.+)$/);
if (branchMatch) {
const branchName = branchMatch[1];
if (branchName && !branches.includes(branchName)) {
branches.push(branchName);
}
// Store commit for this branch
branchCommits.set(branchName, commitHash);
console.log('[GRASP] Stored commit for branch:', branchName, commitHash, 'defaultBranch:', defaultBranch);
// If this is the default branch, use its commit as HEAD commit (fallback if HEAD line didn't work)
if (branchName === defaultBranch && !headCommit) {
headCommit = commitHash;
console.log('[GRASP] Set headCommit from default branch:', headCommit);
}
} else if (refName && refName !== 'HEAD') {
// Log if we found a hash and ref name but it's not a branch
console.log('[GRASP] Found ref but not a branch:', refName, 'commit:', commitHash);
}
}
// If we found a default branch from symref but it's not in branches list, add it
if (defaultBranch && !branches.includes(defaultBranch) && branches.length === 0) {
branches.push(defaultBranch);
}
// If no branches found but we have a default branch, use it
if (branches.length === 0 && defaultBranch) {
branches.push(defaultBranch);
}
// If still no HEAD commit, use the default branch's commit (we already stored it)
if (!headCommit && defaultBranch && branchCommits.has(defaultBranch)) {
headCommit = branchCommits.get(defaultBranch)!;
console.log('[GRASP] Set headCommit from defaultBranch fallback:', headCommit);
}
// If still no HEAD commit, use the first branch's commit
if (!headCommit && branches.length > 0) {
const firstBranchCommit = branchCommits.get(branches[0]);
if (firstBranchCommit) {
headCommit = firstBranchCommit;
console.log('[GRASP] Set headCommit from first branch fallback:', headCommit);
} else {
console.warn('[GRASP] No commit found for first branch:', branches[0], 'branchCommits keys:', Array.from(branchCommits.keys()));
}
}
console.log('[GRASP] Final parse result:', { branches, defaultBranch, headCommit, branchCommitsSize: branchCommits.size });
return { branches, defaultBranch, headCommit };
}
/**
* Get repository info refs using git protocol
* Uses proxy to avoid CORS issues
*/
async function getInfoRefs(repoUrl: string): Promise<{ branches: string[]; defaultBranch: string; headCommit: string } | null> {
try {
// Ensure URL ends with .git
const cleanUrl = repoUrl.endsWith('.git') ? repoUrl : `${repoUrl}.git`;
const refsUrl = `${cleanUrl}/info/refs?service=git-upload-pack`;
// Use proxy endpoint to avoid CORS issues
const proxyUrl = `/api/gitea-proxy/info-refs?url=${encodeURIComponent(refsUrl)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
console.warn(`Failed to fetch info/refs from ${refsUrl}: ${response.status}`);
return null;
}
// Fetch as array buffer first to preserve binary data, then convert to string
const arrayBuffer = await response.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Convert to string (git protocol uses ASCII, so this should be safe)
const text = Array.from(uint8Array).map(b => String.fromCharCode(b)).join('');
// Debug: log first 500 chars and hex dump of first 100 bytes
if (text.length > 0) {
console.log('[GRASP] info/refs response (first 500 chars):', text.substring(0, 500));
console.log('[GRASP] info/refs response length:', text.length, 'chars');
// Hex dump of first 100 bytes
const hexDump = Array.from(uint8Array.slice(0, 100)).map(b => b.toString(16).padStart(2, '0')).join(' ');
console.log('[GRASP] Hex dump (first 100 bytes):', hexDump);
// Log bytes at key positions
if (text.length > 30) {
const bytesAt30 = Array.from(uint8Array.slice(30, 40)).map(b => {
if (b < 32 || b > 126) {
return `\\x${b.toString(16).padStart(2, '0')}`;
}
return String.fromCharCode(b);
}).join('');
const hexAt30 = Array.from(uint8Array.slice(30, 40)).map(b => b.toString(16).padStart(2, '0')).join(' ');
console.log('[GRASP] Bytes at position 30-40 (hex):', hexAt30);
console.log('[GRASP] Bytes at position 30-40 (repr):', bytesAt30);
}
}
const result = parseInfoRefs(text);
// If we got branches but no commits, log the full response for debugging
if (result.branches.length > 0 && !result.headCommit) {
console.warn('[GRASP] Got branches but no headCommit. Full response:', text);
}
return result;
} catch (error) {
console.error('Error fetching info/refs:', error);
return null;
}
}
/**
* Fetch a file from a GRASP repo using git protocol or HTTP
* Tries multiple methods to get file content
* Uses proxy to avoid CORS issues
*/
async function getObjectByPath(repoUrl: string, ref: string, path: string): Promise<string | null> {
try {
const cleanUrl = repoUrl.endsWith('.git') ? repoUrl : `${repoUrl}.git`;
const baseUrl = cleanUrl.replace(/\.git$/, '');
// Try HTTP raw endpoint first (if available) - use proxy
const rawUrl = `${baseUrl}/raw/${ref}/${path}`;
try {
const proxyUrl = `/api/gitea-proxy/raw-file?url=${encodeURIComponent(rawUrl)}`;
const response = await fetch(proxyUrl);
if (response.ok) {
return await response.text();
}
} catch {
// Continue to next method
}
// Try alternative raw endpoint format
const rawUrl2 = `${baseUrl}/-/raw/${ref}/${path}`;
try {
const proxyUrl = `/api/gitea-proxy/raw-file?url=${encodeURIComponent(rawUrl2)}`;
const response = await fetch(proxyUrl);
if (response.ok) {
return await response.text();
}
} catch {
// Continue to next method
}
// Try blob endpoint (needs HTML parsing)
const blobUrl = `${baseUrl}/blob/${ref}/${path}`;
try {
const proxyUrl = `/api/gitea-proxy/raw-file?url=${encodeURIComponent(blobUrl)}`;
const response = await fetch(proxyUrl);
if (response.ok) {
const html = await response.text();
// Try to extract content from HTML (basic extraction)
const contentMatch = html.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i) ||
html.match(/<code[^>]*>([\s\S]*?)<\/code>/i);
if (contentMatch) {
return contentMatch[1].trim();
}
}
} catch {
// Continue
}
return null;
} catch (error) {
console.error(`Error fetching object ${path} from ${repoUrl}:`, error);
return null;
}
}
/**
* Get directory tree structure from GRASP repo
* Uses git protocol or HTTP endpoints
* Uses proxy to avoid CORS issues
*/
async function getDirectoryTreeAt(repoUrl: string, ref: string): Promise<GitFile[]> {
const files: GitFile[] = [];
try {
const cleanUrl = repoUrl.endsWith('.git') ? repoUrl : `${repoUrl}.git`;
const baseUrl = cleanUrl.replace(/\.git$/, '');
// Try HTTP tree/contents endpoint - use proxy
const treeUrl = `${baseUrl}/tree/${ref}`;
try {
const proxyUrl = `/api/gitea-proxy/raw-file?url=${encodeURIComponent(treeUrl)}`;
const response = await fetch(proxyUrl);
if (response.ok) {
const html = await response.text();
// Improved HTML parsing for GRASP/ngit servers
// Look for file and directory links in various formats
const filePattern = /<a[^>]*href="[^"]*\/(?:blob|tree)\/(?:[^"]*\/)?([^"]+)"[^>]*>([^<]+)<\/a>/gi;
const dirPattern = /<a[^>]*href="[^"]*\/tree\/[^"]*\/?([^"]+)"[^>]*>([^<]+)<\/a>/gi;
// Extract files
let match;
while ((match = filePattern.exec(html)) !== null) {
const path = match[1].trim();
const name = match[2].trim();
if (path && name && !path.includes('..') && !name.includes('..')) {
// Avoid duplicates
if (!files.some(f => f.path === path)) {
files.push({
name: name || path.split('/').pop() || path,
path: path,
type: 'file'
});
}
}
}
// Extract directories
while ((match = dirPattern.exec(html)) !== null) {
const path = match[1].trim();
const name = match[2].trim();
if (path && name && !path.includes('..') && !name.includes('..')) {
// Avoid duplicates
if (!files.some(f => f.path === path)) {
files.push({
name: name || path.split('/').pop() || path,
path: path,
type: 'dir'
});
}
}
}
// Also try to find file listings in table/list formats
const tableRowPattern = /<tr[^>]*>[\s\S]*?<a[^>]*href="[^"]*\/(?:blob|tree)\/([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<\/tr>/gi;
while ((match = tableRowPattern.exec(html)) !== null) {
const path = match[1].trim();
const name = match[2].trim();
if (path && name && !path.includes('..')) {
if (!files.some(f => f.path === path)) {
const isDir = match[0].includes('/tree/');
files.push({
name: name || path.split('/').pop() || path,
path: path,
type: isDir ? 'dir' : 'file'
});
}
}
}
if (files.length > 0) {
return files;
}
}
} catch (error) {
console.warn('Failed to fetch tree via HTTP:', error);
}
// Try recursive tree endpoint - use proxy
const recursiveUrl = `${baseUrl}/tree/${ref}?recursive=1`;
try {
const proxyUrl = `/api/gitea-proxy/raw-file?url=${encodeURIComponent(recursiveUrl)}`;
const response = await fetch(proxyUrl);
if (response.ok) {
const html = await response.text();
// Similar parsing as above but for recursive view
const filePattern = /<a[^>]*href="[^"]*\/(?:blob|tree)\/(?:[^"]*\/)?([^"]+)"[^>]*>([^<]+)<\/a>/gi;
let match;
while ((match = filePattern.exec(html)) !== null) {
const path = match[1].trim();
const name = match[2].trim();
if (path && name && !path.includes('..')) {
if (!files.some(f => f.path === path)) {
const pathParts = path.split('/');
files.push({
name: name || pathParts[pathParts.length - 1],
path: path,
type: match[0].includes('/tree/') ? 'dir' : 'file'
});
}
}
}
if (files.length > 0) {
return files;
}
}
} catch (error) {
console.warn('Failed to fetch recursive tree:', error);
}
// If HTTP methods fail, we'd need to implement full git protocol
// For now, return empty array
return files;
} catch (error) {
console.error(`Error fetching directory tree from ${repoUrl}:`, error);
return files;
}
}
/**
* Fetch repository data from GRASP (Git Repository Access via Secure Protocol)
* GRASP servers use git protocol, not REST APIs
*/
async function fetchFromGrasp(npub: string, repo: string, baseUrl: string): Promise<GitRepoInfo | null> {
try {
// Construct the full repository URL
const repoUrl = `${baseUrl.replace('/api/v1', '')}/${npub}/${repo}`;
console.log('[GRASP] Fetching repo:', repoUrl);
// Step 1: Get repository refs using git protocol
console.log('[GRASP] Getting info/refs...');
const refsInfo = await getInfoRefs(repoUrl);
if (!refsInfo) {
console.warn(`[GRASP] Failed to get refs for GRASP repo ${npub}/${repo}`);
return null;
}
const { branches: branchNames, defaultBranch, headCommit } = refsInfo;
console.log('[GRASP] Got refs:', { branches: branchNames, defaultBranch, headCommit });
// Step 2: Get directory tree structure using git protocol
console.log('[GRASP] Getting directory tree using git protocol...');
let files: GitFile[] = [];
let treeFilesWithSha: Array<{ path: string; name: string; type: 'file' | 'dir'; sha: string }> = [];
if (headCommit) {
try {
treeFilesWithSha = await fetchCommitTree(repoUrl, headCommit);
files = treeFilesWithSha.map(f => ({
name: f.name,
path: f.path,
type: f.type,
size: undefined // Size not available from tree entries
}));
console.log('[GRASP] Got files via git protocol:', files.length, 'files/directories');
} catch (error) {
console.warn('[GRASP] Failed to fetch tree via git protocol (will try HTTP fallback):', error);
// Fallback to HTTP tree endpoint
files = await getDirectoryTreeAt(repoUrl, defaultBranch);
console.log('[GRASP] Got files via HTTP fallback:', files.length, 'files/directories');
}
} else {
console.warn('[GRASP] No HEAD commit available, trying HTTP fallback');
files = await getDirectoryTreeAt(repoUrl, defaultBranch);
}
// Step 3: Create branches array (we have branch names but need commit info)
const branches: GitBranch[] = branchNames.map(branchName => ({
name: branchName,
commit: {
sha: headCommit || '',
message: 'No commit message',
author: 'Unknown',
date: new Date().toISOString()
}
}));
// Step 4: Create commits array (minimal - we only have HEAD commit)
const commits: GitCommit[] = headCommit ? [{
sha: headCommit,
message: 'Latest commit',
author: 'Unknown',
date: new Date().toISOString()
}] : [];
// Step 5: Try to fetch README using git protocol
console.log('[GRASP] Fetching README using git protocol...');
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
// First try HTTP endpoints (some servers might expose them)
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) {
const content = await getObjectByPath(repoUrl, defaultBranch, readmeFile);
if (content) {
console.log('[GRASP] Found README via HTTP:', readmeFile);
readme = {
path: readmeFile,
content,
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'
};
break;
}
}
// If HTTP failed, try git protocol by finding README in tree
if (!readme && treeFilesWithSha.length > 0 && headCommit) {
const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i];
let readmeFile: { path: string; sha: string } | null = null;
for (const file of treeFilesWithSha) {
if (file.type === 'file') {
const fileName = file.name;
for (const pattern of readmePatterns) {
if (pattern.test(fileName)) {
readmeFile = { path: file.path, sha: file.sha };
break;
}
}
if (readmeFile) break;
}
}
// If we found a README file, fetch it via git protocol
if (readmeFile && readmeFile.sha) {
try {
console.log('[GRASP] Found README in tree:', readmeFile.path, '- fetching via git protocol...');
const blobObj = await fetchGitObject(repoUrl, readmeFile.sha);
if (blobObj && blobObj.type === 'blob') {
const content = parseGitBlob(blobObj);
if (content) {
const format = readmeFile.path.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown';
readme = {
path: readmeFile.path,
content,
format
};
console.log('[GRASP] Successfully fetched README via git protocol:', readmeFile.path);
}
}
} catch (error) {
console.warn('[GRASP] Failed to fetch README blob via git protocol:', error);
}
}
}
if (!readme) {
console.log('[GRASP] No README found');
}
const result = {
name: repo,
description: undefined, // GRASP repos don't expose description via git protocol
url: repoUrl,
defaultBranch,
branches,
commits,
files,
readme
};
console.log('[GRASP] Fetch complete:', {
name: result.name,
branches: result.branches.length,
commits: result.commits.length,
files: result.files.length,
hasReadme: !!result.readme
});
return result;
} catch (error) {
console.error('[GRASP] Error fetching from GRASP:', error);
return null;
}
}
/**
* Fetch repository data from a git URL
*/
@ -760,6 +1343,8 @@ export async function fetchGitRepo(url: string): Promise<GitRepoInfo | null> { @@ -760,6 +1343,8 @@ export async function fetchGitRepo(url: string): Promise<GitRepoInfo | null> {
case 'onedev':
// OneDev uses a similar API structure to Gitea, so we can use the same handler
return fetchFromGitea(owner, repo, baseUrl);
case 'grasp':
return fetchFromGrasp(owner, repo, baseUrl);
default:
console.error('Unsupported platform:', platform);
return null;
@ -769,12 +1354,17 @@ export async function fetchGitRepo(url: string): Promise<GitRepoInfo | null> { @@ -769,12 +1354,17 @@ export async function fetchGitRepo(url: string): Promise<GitRepoInfo | null> {
/**
* Convert SSH git URL to HTTPS format
*/
function convertSshToHttps(url: string): string | null {
export function convertSshToHttps(url: string): string | null {
// Handle git@host:user/repo.git format
const sshMatch = url.match(/git@([^:]+):(.+?)(?:\.git)?$/);
// Examples:
// git@github.com:hzrd149/nostrudel.git -> https://github.com/hzrd149/nostrudel.git
// git@gitlab.com:user/repo -> https://gitlab.com/user/repo.git
const sshMatch = url.match(/^git@([^:]+):(.+)$/);
if (sshMatch) {
const [, host, path] = sshMatch;
return `https://${host}/${path}${path.endsWith('.git') ? '' : '.git'}`;
// Ensure path ends with .git
const normalizedPath = path.endsWith('.git') ? path : `${path}.git`;
return `https://${host}/${normalizedPath}`;
}
return null;
}
@ -800,8 +1390,8 @@ export function extractGitUrls(event: { tags: string[][]; content: string }): st @@ -800,8 +1390,8 @@ export function extractGitUrls(event: { tags: string[][]; content: string }): st
}
}
// Check if it's a git URL
if (url.includes('github.com') || url.includes('gitlab.com') || url.includes('gitea') || url.includes('.git') || url.startsWith('http')) {
// Check if it's a git URL (including GRASP URLs with npub)
if (url.includes('github.com') || url.includes('gitlab.com') || url.includes('gitea') || url.includes('.git') || url.includes('/npub1') || url.startsWith('http')) {
urls.push(url);
}
}

357
src/lib/services/git/git-protocol-client.ts

@ -0,0 +1,357 @@ @@ -0,0 +1,357 @@
/**
* Git Protocol Client
* Implements git protocol over HTTP for fetching repository objects
* Supports git-upload-pack protocol for fetching commits, trees, and blobs
*/
export interface GitObject {
type: 'commit' | 'tree' | 'blob' | 'tag';
sha: string;
size: number;
data: Uint8Array;
}
export interface GitTreeEntry {
mode: string;
type: 'blob' | 'tree';
sha: string;
name: string;
path: string;
}
export interface GitCommit {
sha: string;
tree: string;
parents: string[];
author: string;
committer: string;
message: string;
timestamp: number;
}
/**
* Fetch git objects using git-upload-pack protocol
* This is a simplified implementation that fetches specific objects
*/
export async function fetchGitObjects(
repoUrl: string,
wantShas: string[]
): Promise<Map<string, GitObject>> {
const objects = new Map<string, GitObject>();
try {
const cleanUrl = repoUrl.endsWith('.git') ? repoUrl : `${repoUrl}.git`;
const uploadPackUrl = `${cleanUrl}/git-upload-pack`;
// Build git-upload-pack request
// Format: "want <sha>\n" for each object, then "done\n"
let requestBody = '';
for (const sha of wantShas) {
requestBody += `want ${sha}\n`;
}
requestBody += 'done\n';
// Use proxy to avoid CORS
const proxyUrl = `/api/gitea-proxy/git-upload-pack?url=${encodeURIComponent(uploadPackUrl)}`;
const response = await fetch(proxyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-git-upload-pack-request',
'Accept': 'application/x-git-upload-pack-result'
},
body: requestBody
});
if (!response.ok) {
console.warn(`Failed to fetch git objects: ${response.status}`);
return objects;
}
// Parse packfile response
// This is simplified - full packfile parsing is complex
const arrayBuffer = await response.arrayBuffer();
const data = new Uint8Array(arrayBuffer);
// Basic packfile parsing (simplified)
// Real packfiles have a complex format with deltas, etc.
// For now, we'll use a simpler approach: fetch objects individually via HTTP
return objects;
} catch (error) {
console.error('Error fetching git objects:', error);
return objects;
}
}
/**
* Fetch a single git object by SHA
* Uses HTTP endpoint with decompression via proxy
*/
export async function fetchGitObject(
repoUrl: string,
sha: string
): Promise<GitObject | null> {
try {
const cleanUrl = repoUrl.endsWith('.git') ? repoUrl : `${repoUrl}.git`;
// Try HTTP endpoint with decompression (proxy handles zlib decompression)
const objectUrl = `${cleanUrl}/objects/${sha.substring(0, 2)}/${sha.substring(2)}`;
try {
const proxyUrl = `/api/gitea-proxy/git-object?url=${encodeURIComponent(objectUrl)}`;
const response = await fetch(proxyUrl);
if (response.ok) {
const data = new Uint8Array(await response.arrayBuffer());
// Parse git object format (already decompressed by proxy)
return parseGitObject(data, sha);
}
} catch (error) {
console.warn(`Failed to fetch git object via HTTP: ${error}`);
}
// Fallback: use git-upload-pack for single object
const objects = await fetchGitObjects(repoUrl, [sha]);
return objects.get(sha) || null;
} catch (error) {
console.error(`Error fetching git object ${sha}:`, error);
return null;
}
}
/**
* Parse git object from raw data (already decompressed by proxy)
* Git objects have header: "<type> <size>\0<data>"
*/
function parseGitObject(data: Uint8Array, sha: string): GitObject | null {
try {
// Data is already decompressed by proxy
// Format: "commit 1234\0<data>" or "tree 5678\0<data>" etc.
const text = new TextDecoder().decode(data);
const headerMatch = text.match(/^(\w+) (\d+)\0/);
if (!headerMatch) {
return null;
}
const type = headerMatch[1] as GitObject['type'];
const size = parseInt(headerMatch[2], 10);
const contentStart = headerMatch[0].length;
const content = data.slice(contentStart);
return {
type,
sha,
size,
data: content
};
} catch (error) {
console.error('Error parsing git object:', error);
return null;
}
}
/**
* Parse git commit object
*/
export function parseGitCommit(object: GitObject): GitCommit | null {
if (object.type !== 'commit') {
return null;
}
try {
const text = new TextDecoder().decode(object.data);
const lines = text.split('\n');
let tree = '';
const parents: string[] = [];
let author = '';
let committer = '';
let timestamp = 0;
const messageLines: string[] = [];
let inMessage = false;
for (const line of lines) {
if (inMessage) {
messageLines.push(line);
continue;
}
if (line.startsWith('tree ')) {
tree = line.substring(5).trim();
} else if (line.startsWith('parent ')) {
parents.push(line.substring(7).trim());
} else if (line.startsWith('author ')) {
author = line.substring(7).trim();
// Extract timestamp from author line: "Author Name <email> timestamp timezone"
const timestampMatch = author.match(/\s(\d+)\s[+-]\d+$/);
if (timestampMatch) {
timestamp = parseInt(timestampMatch[1], 10);
}
} else if (line.startsWith('committer ')) {
committer = line.substring(10).trim();
} else if (line === '') {
inMessage = true;
}
}
return {
sha: object.sha,
tree,
parents,
author,
committer,
message: messageLines.join('\n'),
timestamp
};
} catch (error) {
console.error('Error parsing git commit:', error);
return null;
}
}
/**
* Parse git tree object
*/
export function parseGitTree(object: GitObject): GitTreeEntry[] | null {
if (object.type !== 'tree') {
return null;
}
try {
const entries: GitTreeEntry[] = [];
let pos = 0;
const data = object.data;
while (pos < data.length) {
// Tree entry format: "<mode> <name>\0<20-byte-sha>"
// Mode is like "100644" (file) or "40000" (tree)
// Find null byte (separates name from SHA)
let nullPos = pos;
while (nullPos < data.length && data[nullPos] !== 0) {
nullPos++;
}
if (nullPos >= data.length) break;
const header = new TextDecoder().decode(data.slice(pos, nullPos));
const [mode, ...nameParts] = header.split(' ');
const name = nameParts.join(' ');
const shaStart = nullPos + 1;
if (shaStart + 20 > data.length) break;
// SHA is 20 bytes (binary)
const shaBytes = data.slice(shaStart, shaStart + 20);
const sha = Array.from(shaBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const type = mode.startsWith('100') ? 'blob' : 'tree';
entries.push({
mode,
type,
sha,
name,
path: name // Will be set by caller with full path
});
pos = shaStart + 20;
}
return entries;
} catch (error) {
console.error('Error parsing git tree:', error);
return null;
}
}
/**
* Parse git blob object (just returns the data)
*/
export function parseGitBlob(object: GitObject): string | null {
if (object.type !== 'blob') {
return null;
}
try {
return new TextDecoder().decode(object.data);
} catch (error) {
console.error('Error parsing git blob:', error);
return null;
}
}
/**
* Fetch commit tree recursively to get all files
*/
export async function fetchCommitTree(
repoUrl: string,
commitSha: string,
basePath: string = ''
): Promise<Array<{ path: string; name: string; type: 'file' | 'dir'; sha: string }>> {
const files: Array<{ path: string; name: string; type: 'file' | 'dir'; sha: string }> = [];
try {
// Fetch commit
const commitObj = await fetchGitObject(repoUrl, commitSha);
if (!commitObj || commitObj.type !== 'commit') {
return files;
}
const commit = parseGitCommit(commitObj);
if (!commit) {
return files;
}
// Fetch root tree
await fetchTreeRecursive(repoUrl, commit.tree, basePath, files);
return files;
} catch (error) {
console.error('Error fetching commit tree:', error);
return files;
}
}
/**
* Recursively fetch tree entries
*/
async function fetchTreeRecursive(
repoUrl: string,
treeSha: string,
basePath: string,
files: Array<{ path: string; name: string; type: 'file' | 'dir'; sha: string }>
): Promise<void> {
const treeObj = await fetchGitObject(repoUrl, treeSha);
if (!treeObj || treeObj.type !== 'tree') {
return;
}
const entries = parseGitTree(treeObj);
if (!entries) {
return;
}
for (const entry of entries) {
const fullPath = basePath ? `${basePath}/${entry.name}` : entry.name;
if (entry.type === 'tree') {
files.push({
path: fullPath,
name: entry.name,
type: 'dir',
sha: entry.sha
});
// Recursively fetch subdirectory
await fetchTreeRecursive(repoUrl, entry.sha, fullPath, files);
} else {
files.push({
path: fullPath,
name: entry.name,
type: 'file',
sha: entry.sha
});
}
}
}

16
src/lib/services/nostr/config.ts

@ -18,14 +18,20 @@ const PROFILE_RELAYS = [ @@ -18,14 +18,20 @@ const PROFILE_RELAYS = [
'wss://nostr21.com'
];
const THREAD_PUBLISH_RELAYS = [
const DOCUMENTATION_PUBLISH_RELAYS = [
'wss://thecitadel.nostr1.com'
];
const GIF_RELAYS = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.gifbuddy.lol",
"wss://relay.gifbuddy.lol"
];
const GRASP_RELAYS = [
"wss://relay.ngit.dev",
"wss://gitnostr.com",
"wss://ngit.danconwaydev.com",
"wss://thecitadel.nostr1.com"
];
@ -54,10 +60,11 @@ export interface NostrConfig { @@ -54,10 +60,11 @@ export interface NostrConfig {
defaultRelays: string[];
profileRelays: string[];
threadTimeoutDays: number;
threadPublishRelays: string[];
documentationPublishRelays: string[];
relayTimeout: number;
gifRelays: string[];
readOnlyRelays: string[];
graspRelays: string[];
// Fetch limits
feedLimit: number;
singleEventLimit: number;
@ -94,10 +101,11 @@ export function getConfig(): NostrConfig { @@ -94,10 +101,11 @@ export function getConfig(): NostrConfig {
defaultRelays: parseRelays(import.meta.env.VITE_DEFAULT_RELAYS, DEFAULT_RELAYS),
profileRelays: PROFILE_RELAYS,
threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30),
threadPublishRelays: THREAD_PUBLISH_RELAYS,
documentationPublishRelays: DOCUMENTATION_PUBLISH_RELAYS,
relayTimeout: RELAY_TIMEOUT,
gifRelays: GIF_RELAYS,
readOnlyRelays: READ_ONLY_RELAYS,
graspRelays: GRASP_RELAYS,
// Fetch limits
feedLimit: parseIntEnv(import.meta.env.VITE_FEED_LIMIT, FEED_LIMIT, 1),
singleEventLimit: SINGLE_EVENT_LIMIT,

4
src/lib/services/nostr/relay-manager.ts

@ -269,10 +269,10 @@ class RelayManager { @@ -269,10 +269,10 @@ class RelayManager {
/**
* Get relays for publishing threads (kind 11)
*/
getThreadPublishRelays(): string[] {
getdocumentationPublishRelays(): string[] {
return this.getPublishRelays([
...config.defaultRelays,
...config.threadPublishRelays
...config.documentationPublishRelays
]);
}

2
src/lib/types/kind-lookup.ts

@ -98,6 +98,7 @@ export const KIND = { @@ -98,6 +98,7 @@ export const KIND = {
FOLOW_SET: 30000,
HTTP_AUTH: 27235,
REPO_ANNOUNCEMENT: 30617,
USER_GRASP_LIST: 10317,
ISSUE: 1621,
STATUS_OPEN: 1630,
STATUS_APPLIED: 1631,
@ -165,6 +166,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -165,6 +166,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
// Repository (NIP-34)
[KIND.REPO_ANNOUNCEMENT]: { number: KIND.REPO_ANNOUNCEMENT, description: 'Repository Announcement', showInFeed: false, isSecondaryKind: false },
[KIND.USER_GRASP_LIST]: { number: KIND.USER_GRASP_LIST, description: 'User Grasp List', showInFeed: false, isSecondaryKind: false },
[KIND.ISSUE]: { number: KIND.ISSUE, description: 'Issue', showInFeed: true, isSecondaryKind: false },
[KIND.STATUS_OPEN]: { number: KIND.STATUS_OPEN, description: 'Status: Open', showInFeed: false, isSecondaryKind: true },
[KIND.STATUS_APPLIED]: { number: KIND.STATUS_APPLIED, description: 'Status: Applied/Merged/Resolved', showInFeed: false, isSecondaryKind: true },

23
src/lib/types/kind-metadata.ts

@ -1051,6 +1051,29 @@ export const KIND_METADATA: Record<number, KindMetadata> = { @@ -1051,6 +1051,29 @@ export const KIND_METADATA: Record<number, KindMetadata> = {
sig: '...'
})
},
[KIND.USER_GRASP_LIST]: {
...KIND_LOOKUP[KIND.USER_GRASP_LIST],
writable: true,
requiresContent: false, // Content is empty for grasp list
helpText: {
description: 'User grasp list (NIP-34). List of GRASP servers the user wishes to use for NIP-34 related activity. Similar to NIP-65 relay list. The event SHOULD include a list of `g` tags with grasp service websocket URLs in order of preference.',
suggestedTags: ['g (grasp-service-websocket-url) - zero or more grasp server URLs']
},
exampleJSON: (pubkey, eventId, relay, timestamp) => ({
kind: KIND.USER_GRASP_LIST,
pubkey,
created_at: timestamp,
content: '',
tags: [
['g', 'wss://relay.ngit.dev'],
['g', 'wss://gitnostr.com'],
['g', 'wss://ngit.danconwaydev.com']
],
id: '...',
sig: '...'
})
},
};
/**

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

@ -1,4 +1,9 @@ @@ -1,4 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit';
import { inflateRaw, gunzip } from 'zlib';
import { promisify } from 'util';
const inflateRawAsync = promisify(inflateRaw);
const gunzipAsync = promisify(gunzip);
/**
* Proxy endpoint for Git hosting API requests (Gitea, GitLab, OneDev, etc.) to avoid CORS issues
@ -274,10 +279,121 @@ function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearc @@ -274,10 +279,121 @@ function buildTargetUrl(baseUrl: string, apiPath: string, searchParams: URLSearc
export const GET: RequestHandler = async ({ params, url }) => {
try {
const baseUrl = url.searchParams.get('baseUrl');
// params.path might be a string or array depending on SvelteKit version
// Handle special GRASP endpoints
const apiPath = Array.isArray(params.path) ? params.path.join('/') : params.path;
// Special endpoint: info-refs (for GRASP git protocol)
if (apiPath === 'info-refs') {
const targetUrl = url.searchParams.get('url');
if (!targetUrl) {
return createErrorResponse('Missing url query parameter for info-refs', 400);
}
const response = await fetch(targetUrl, {
method: 'GET',
headers: {
'Accept': 'application/x-git-upload-pack-advertisement',
'User-Agent': 'git/2.0'
}
});
if (!response.ok) {
return createErrorResponse(`Failed to fetch info-refs: ${response.status}`, response.status);
}
const body = await response.text();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': 'application/x-git-upload-pack-advertisement',
...CORS_HEADERS
}
});
}
// Special endpoint: raw-file (for GRASP file fetching)
if (apiPath === 'raw-file') {
const targetUrl = url.searchParams.get('url');
if (!targetUrl) {
return createErrorResponse('Missing url query parameter for raw-file', 400);
}
const response = await fetch(targetUrl, {
method: 'GET',
headers: {
'Accept': 'text/plain, text/html, */*',
'User-Agent': 'aitherboard/1.0'
}
});
if (!response.ok) {
return createErrorResponse(`Failed to fetch file: ${response.status}`, response.status);
}
const contentType = response.headers.get('content-type') || 'text/plain';
const body = await response.text();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': contentType,
...CORS_HEADERS
}
});
}
// Special endpoint: git-object (for GRASP git object fetching with decompression)
if (apiPath === 'git-object') {
const targetUrl = url.searchParams.get('url');
if (!targetUrl) {
return createErrorResponse('Missing url query parameter for git-object', 400);
}
const response = await fetch(targetUrl, {
method: 'GET',
headers: {
'Accept': 'application/octet-stream',
'User-Agent': 'git/2.0'
}
});
if (!response.ok) {
return createErrorResponse(`Failed to fetch git object: ${response.status}`, response.status);
}
const compressedData = Buffer.from(await response.arrayBuffer());
try {
// Git objects are zlib-compressed (deflate)
// Try inflateRaw first (most common), then gunzip as fallback
let decompressed: Buffer;
try {
decompressed = await inflateRawAsync(compressedData);
} catch {
// Fallback to gunzip if inflateRaw fails
decompressed = await gunzipAsync(compressedData);
}
// Convert Buffer to Uint8Array for Response
const uint8Array = new Uint8Array(decompressed);
return new Response(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
...CORS_HEADERS
}
});
} catch (error) {
console.error('Error decompressing git object:', error);
return createErrorResponse('Failed to decompress git object', 500);
}
}
// Standard Gitea/GitLab API proxy handling
const baseUrl = url.searchParams.get('baseUrl');
if (!baseUrl) {
return createErrorResponse('Missing baseUrl query parameter', 400);
}
@ -341,6 +457,54 @@ export const GET: RequestHandler = async ({ params, url }) => { @@ -341,6 +457,54 @@ export const GET: RequestHandler = async ({ params, url }) => {
}
};
export const POST: RequestHandler = async ({ params, url, request }) => {
try {
const apiPath = Array.isArray(params.path) ? params.path.join('/') : params.path;
// Special endpoint: git-upload-pack (for GRASP git protocol)
if (apiPath === 'git-upload-pack') {
const targetUrl = url.searchParams.get('url');
if (!targetUrl) {
return createErrorResponse('Missing url query parameter for git-upload-pack', 400);
}
const requestBody = await request.text();
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-git-upload-pack-request',
'Accept': 'application/x-git-upload-pack-result',
'User-Agent': 'git/2.0'
},
body: requestBody
});
if (!response.ok) {
return createErrorResponse(`Failed to fetch git-upload-pack: ${response.status}`, response.status);
}
const contentType = response.headers.get('content-type') || 'application/x-git-upload-pack-result';
const body = await response.arrayBuffer();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': contentType,
...CORS_HEADERS
}
});
}
return createErrorResponse('POST not supported for this endpoint', 405);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Git protocol proxy error:', message);
return createErrorResponse(message, 500);
}
};
export const OPTIONS: RequestHandler = async () => {
return new Response(null, {
status: 204,

6
src/routes/relay/+page.svelte

@ -31,7 +31,7 @@ @@ -31,7 +31,7 @@
if (config.profileRelays.includes(url)) {
categories.push('Profile');
}
if (config.threadPublishRelays.includes(url)) {
if (config.documentationPublishRelays.includes(url)) {
categories.push('Thread Publish');
}
if (config.gifRelays.includes(url)) {
@ -59,7 +59,7 @@ @@ -59,7 +59,7 @@
config.profileRelays.forEach(r => allRelays.add(r));
// Add thread publish relays
config.threadPublishRelays.forEach(r => allRelays.add(r));
config.documentationPublishRelays.forEach(r => allRelays.add(r));
// Add gif relays
config.gifRelays.forEach(r => allRelays.add(r));
@ -81,7 +81,7 @@ @@ -81,7 +81,7 @@
// Check if it's a local relay
const isLocalRelay = currentPubkey && !config.defaultRelays.includes(url) &&
!config.profileRelays.includes(url) &&
!config.threadPublishRelays.includes(url) &&
!config.documentationPublishRelays.includes(url) &&
!config.gifRelays.includes(url);
if (isLocalRelay && !categories.includes('Local')) {
categories.push('Local');

141
src/routes/repos/+page.svelte

@ -13,6 +13,8 @@ @@ -13,6 +13,8 @@
import { getRecentCachedEvents } from '../../lib/services/cache/event-cache.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import Icon from '../../lib/components/ui/Icon.svelte';
import { convertSshToHttps } from '../../lib/services/content/git-repo-fetcher.js';
let repos = $state<NostrEvent[]>([]);
let loading = $state(false); // Start as false - will be set to true only if no cache
@ -21,7 +23,8 @@ @@ -21,7 +23,8 @@
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null);
let searchQuery = $state('');
let showMyRepos = $state(false);
let showMyRepos = $state(true); // Default to checked to filter spam
let allowedPubkeys = $state<Set<string>>(new Set()); // Set of pubkeys to show (user + contacts)
const isLoggedIn = $derived(sessionManager.isLoggedIn());
const currentPubkey = $derived(sessionManager.getCurrentPubkey());
@ -49,6 +52,10 @@ @@ -49,6 +52,10 @@
onMount(async () => {
await nostrClient.initialize();
// Load contacts if user is logged in and filter is enabled
if (isLoggedIn && showMyRepos && currentPubkey) {
await loadContacts();
}
// Only load from network if we don't have cached repos
if (repos.length === 0) {
await loadRepos();
@ -58,6 +65,52 @@ @@ -58,6 +65,52 @@
}
});
// Load contacts from kind 3 event
async function loadContacts() {
if (!currentPubkey) return;
try {
const relays = relayManager.getProfileReadRelays();
const contactsEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.CONTACTS], authors: [currentPubkey], limit: 1 }],
relays,
{ useCache: 'cache-first', cacheResults: true }
);
const allowed = new Set<string>();
// Always include the user themselves
allowed.add(currentPubkey.toLowerCase());
// Add contacts from kind 3 event
if (contactsEvents.length > 0) {
const contactsEvent = contactsEvents[0];
const contactPubkeys = contactsEvent.tags
.filter(t => Array.isArray(t) && t[0] === 'p' && t[1])
.map(t => (t[1] as string).toLowerCase());
for (const pubkey of contactPubkeys) {
allowed.add(pubkey);
}
}
allowedPubkeys = allowed;
} catch (error) {
console.warn('Failed to load contacts:', error);
// Fallback: only include user themselves
allowedPubkeys = new Set([currentPubkey.toLowerCase()]);
}
}
// Reload contacts when checkbox state changes
$effect(() => {
if (isLoggedIn && showMyRepos && currentPubkey) {
loadContacts();
} else if (!showMyRepos) {
// Clear allowed pubkeys when filter is disabled
allowedPubkeys = new Set();
}
});
async function loadCachedRepos() {
try {
// Load cached repos (within 1 hour - optimized for slow connections)
@ -251,7 +304,15 @@ @@ -251,7 +304,15 @@
if (!Array.isArray(event.tags)) return [];
return event.tags
.filter(t => Array.isArray(t) && t[0] === 'clone' && t[1])
.map(t => String(t[1]));
.map(t => {
const url = String(t[1]);
// Convert SSH URLs to HTTPS
if (url.startsWith('git@')) {
const httpsUrl = convertSshToHttps(url);
return httpsUrl || url; // Fallback to original if conversion fails
}
return url;
});
}
function getWebUrls(event: NostrEvent): string[] {
@ -391,16 +452,16 @@ @@ -391,16 +452,16 @@
let filtered = Array.from(reposByKey.values());
// Filter by "See my repos" checkbox
if (showMyRepos && currentPubkey) {
// Filter by "Show repos from myself and my contacts" checkbox
if (showMyRepos && allowedPubkeys.size > 0) {
filtered = filtered.filter(repo => {
// Check if repo owner matches
if (repo.pubkey.toLowerCase() === currentPubkey.toLowerCase()) {
// Check if repo owner is in allowed list (user or contact)
if (allowedPubkeys.has(repo.pubkey.toLowerCase())) {
return true;
}
// Check if user is a maintainer
// Check if any maintainer is in allowed list
const maintainers = getMaintainers(repo);
return maintainers.some(m => m.toLowerCase() === currentPubkey.toLowerCase());
return maintainers.some(m => allowedPubkeys.has(m.toLowerCase()));
});
}
@ -485,21 +546,37 @@ @@ -485,21 +546,37 @@
// Otherwise, filter the loaded repos
let filtered = repos;
// Filter by "See my repos" checkbox
if (showMyRepos && currentPubkey) {
// Filter by "Show repos from myself and my contacts" checkbox
if (showMyRepos && allowedPubkeys.size > 0) {
filtered = filtered.filter(repo => {
// Check if repo owner matches
if (repo.pubkey.toLowerCase() === currentPubkey.toLowerCase()) {
// Check if repo owner is in allowed list (user or contact)
if (allowedPubkeys.has(repo.pubkey.toLowerCase())) {
return true;
}
// Check if user is a maintainer
// Check if any maintainer is in allowed list
const maintainers = getMaintainers(repo);
return maintainers.some(m => m.toLowerCase() === currentPubkey.toLowerCase());
return maintainers.some(m => allowedPubkeys.has(m.toLowerCase()));
});
}
return filtered;
});
function publishRepository() {
// Build kind 30617 event data with minimal pre-population
// User will enter clone URLs, name, description, etc. in the form
const repoAnnouncementData = {
kind: 30617,
content: '',
tags: [
// d tag will be auto-generated from name or user can enter manually
]
};
// Store in sessionStorage and navigate to write page
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(repoAnnouncementData));
goto('/write');
}
</script>
<Header />
@ -519,8 +596,12 @@ @@ -519,8 +596,12 @@
bind:checked={showMyRepos}
class="checkbox-input"
/>
<span>See my repos</span>
<span>Show repos from myself and my contacts (kind 3 list)</span>
</label>
<button onclick={publishRepository} class="publish-repo-btn">
<Icon name="upload" size={16} />
Publish Repository
</button>
{/if}
</div>
@ -602,15 +683,17 @@ @@ -602,15 +683,17 @@
role="button"
tabindex="0"
>
<div class="repo-header">
{#if getImageUrl(repo)}
<div class="repo-image-container">
<img src={getImageUrl(repo)!} alt="{getRepoName(repo)}" class="repo-image" />
</div>
{/if}
<div class="repo-header">
<div class="repo-header-text">
<h3 class="repo-name">{getRepoName(repo)}</h3>
<span class="repo-kind">Kind {repo.kind}</span>
</div>
</div>
<div class="repo-owner">
<span class="repo-owner-label">Owner:</span>
<ProfileBadge pubkey={repo.pubkey} inline={true} />
@ -762,12 +845,15 @@ @@ -762,12 +845,15 @@
}
.repo-image-container {
width: 100%;
height: 200px;
flex-shrink: 0;
width: 3rem;
height: 3rem;
border-radius: 50%;
overflow: hidden;
border-radius: 0.375rem;
margin-bottom: 1rem;
margin-bottom: 0;
margin-right: 0.75rem;
background: var(--fog-highlight, #f3f4f6);
border: 2px solid var(--fog-border, #e5e7eb);
display: flex;
align-items: center;
justify-content: center;
@ -775,13 +861,13 @@ @@ -775,13 +861,13 @@
:global(.dark) .repo-image-container {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #374151);
}
.repo-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.375rem;
object-fit: contain;
}
:global(.dark) .repo-item {
@ -801,12 +887,19 @@ @@ -801,12 +887,19 @@
}
.repo-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.repo-header-text {
flex: 1;
min-width: 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.5rem;
min-width: 0;
}
.repo-name {

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

@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { fetchGitRepo, extractGitUrls, type GitRepoInfo, type GitFile } from '../../../lib/services/content/git-repo-fetcher.js';
import { fetchGitRepo, extractGitUrls, isGraspUrl, convertSshToHttps, type GitRepoInfo, type GitFile } from '../../../lib/services/content/git-repo-fetcher.js';
import FileExplorer from '../../../lib/components/content/FileExplorer.svelte';
import { marked } from 'marked';
import Asciidoctor from 'asciidoctor';
@ -108,9 +108,18 @@ @@ -108,9 +108,18 @@
try {
const gitUrls = extractGitUrls(repoEvent);
if (gitUrls.length > 0) {
// Prioritize GRASP clones if multiple URLs exist
let prioritizedUrls = gitUrls;
if (gitUrls.length > 1) {
const graspUrls = gitUrls.filter(url => isGraspUrl(url));
const nonGraspUrls = gitUrls.filter(url => !isGraspUrl(url));
// Put GRASP URLs first
prioritizedUrls = [...graspUrls, ...nonGraspUrls];
}
if (prioritizedUrls.length > 0) {
// Try each URL until one works
for (const url of gitUrls) {
for (const url of prioritizedUrls) {
try {
const repo = await fetchGitRepo(url);
if (repo) {
@ -119,6 +128,7 @@ @@ -119,6 +128,7 @@
}
} catch (error) {
// Failed to fetch git repo - continue to next URL
console.warn(`Failed to fetch repo from ${url}:`, error);
}
}
}
@ -236,7 +246,7 @@ @@ -236,7 +246,7 @@
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
{ useCache: 'cache-first', cacheResults: true }
);
@ -322,9 +332,40 @@ @@ -322,9 +332,40 @@
issueEvents.push(...events);
}
// Deduplicate and sort
// Deduplicate
const uniqueIssues = Array.from(new Map(issueEvents.map(e => [e.id, e])).values());
issues = uniqueIssues.sort((a, b) => b.created_at - a.created_at);
// Filter to only include issues that actually match this repo
// Check if issue has 'a' tag matching this repo, or 'e' tag matching repo event ID, or 'r' tag matching repo URLs
const repoATag = dTag ? `${repoEvent.kind}:${repoEvent.pubkey}:${dTag}` : null;
const repoEventId = repoEvent.id;
const matchingIssues = uniqueIssues.filter(issue => {
// Check if issue references this repo via 'a' tag
if (repoATag) {
const aTags = issue.tags.filter(t => t[0] === 'a').map(t => t[1]);
if (aTags.includes(repoATag)) {
return true;
}
}
// Check if issue references this repo via 'e' tag
const eTags = issue.tags.filter(t => t[0] === 'e').map(t => t[1]);
if (eTags.includes(repoEventId)) {
return true;
}
// Check if issue references this repo via 'r' tag (git URLs)
const rTags = issue.tags.filter(t => t[0] === 'r').map(t => t[1]);
for (const gitUrl of gitUrls) {
if (rTags.includes(gitUrl)) {
return true;
}
}
return false;
});
issues = matchingIssues.sort((a, b) => b.created_at - a.created_at);
loadingIssues = false; // Issues are loaded, show them immediately
// Load statuses, comments, and profiles in background (don't wait)
@ -709,7 +750,7 @@ @@ -709,7 +750,7 @@
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
allRelays,
{ useCache: true, cacheResults: true }
{ useCache: 'cache-first', cacheResults: true }
);
if (events.length > 0) {
@ -795,7 +836,15 @@ @@ -795,7 +836,15 @@
if (!repoEvent || !Array.isArray(repoEvent.tags)) return [];
return repoEvent.tags
.filter(t => Array.isArray(t) && t[0] === 'clone' && t[1])
.map(t => String(t[1]));
.map(t => {
const url = String(t[1]);
// Convert SSH URLs to HTTPS
if (url.startsWith('git@')) {
const httpsUrl = convertSshToHttps(url);
return httpsUrl || url; // Fallback to original if conversion fails
}
return url;
});
}
function getRelays(): string[] {
@ -1259,11 +1308,6 @@ @@ -1259,11 +1308,6 @@
<div class="readme-container">
{@html renderReadme(gitRepo.readme.content, gitRepo.readme.format)}
</div>
{:else if gitRepoFetchAttempted && !loadingGitRepo && extractGitUrls(repoEvent).some(url => /\/npub1[a-z0-9]+/i.test(url))}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">GRASP (Git Repository Access via Secure Protocol) repositories are not yet supported for fetching README files.</p>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">The repository description is shown in the header above.</p>
</div>
{:else}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No README found.</p>
@ -1325,11 +1369,6 @@ @@ -1325,11 +1369,6 @@
</div>
{/if}
</div>
{:else if gitRepoFetchAttempted && !loadingGitRepo && extractGitUrls(repoEvent).some(url => /\/npub1[a-z0-9]+/i.test(url))}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">GRASP (Git Repository Access via Secure Protocol) repositories are not yet supported for fetching repository data.</p>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">GRASP uses a different protocol than standard git hosting services and cannot be accessed via their APIs.</p>
</div>
{:else}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">Git repository data not available.</p>
@ -1550,9 +1589,9 @@ @@ -1550,9 +1589,9 @@
.repo-profile-image-container {
flex-shrink: 0;
width: 120px;
height: 120px;
border-radius: 0.5rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
overflow: hidden;
background: var(--fog-highlight, #f3f4f6);
border: 2px solid var(--fog-border, #e5e7eb);
@ -1569,7 +1608,7 @@ @@ -1569,7 +1608,7 @@
.repo-profile-image {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
}
.repo-title-section {

3
static/changelog.yaml

@ -1,4 +1,7 @@ @@ -1,4 +1,7 @@
versions:
'0.3.3':
- 'Added GRASP repository management'
- 'Support manual creation and editing of User Grasp List (kind 10317) and repo announcements (kind 30617)'
'0.3.2':
- 'Expanded /repos to handle GitLab, Gitea, and OneDev repositories'
- 'Added back and refresh buttons to all pages'

6
static/healthz.json

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
{
"status": "ok",
"service": "aitherboard",
"version": "0.3.2",
"buildTime": "2026-02-14T17:51:24.287Z",
"version": "0.3.3",
"buildTime": "2026-02-15T06:43:22.492Z",
"gitCommit": "unknown",
"timestamp": 1771091484287
"timestamp": 1771137802493
}
Loading…
Cancel
Save