commit
cf514964d4
23 changed files with 5517 additions and 0 deletions
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
.DS_Store |
||||
node_modules |
||||
/build |
||||
/.svelte-kit |
||||
/package |
||||
.env |
||||
.env.* |
||||
!.env.example |
||||
npm-debug.log |
||||
yarn-error.log |
||||
@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
|
||||
# Implementation Guide for git-http-backend Integration |
||||
|
||||
## Overview |
||||
|
||||
The git-http-backend integration needs to be implemented in `/src/routes/api/git/[...path]/+server.ts`. This route will handle all git HTTP operations (clone, push, pull). |
||||
|
||||
## URL Structure |
||||
|
||||
All git requests will follow this pattern: |
||||
- `GET /api/git/{npub}/{repo-name}.git/info/refs?service=git-upload-pack` (clone/fetch) |
||||
- `GET /api/git/{npub}/{repo-name}.git/info/refs?service=git-receive-pack` (push capability check) |
||||
- `POST /api/git/{npub}/{repo-name}.git/git-upload-pack` (fetch) |
||||
- `POST /api/git/{npub}/{repo-name}.git/git-receive-pack` (push) |
||||
|
||||
## Implementation Steps |
||||
|
||||
### 1. Parse Request Path |
||||
|
||||
Extract `npub` and `repo-name` from the path parameter: |
||||
```typescript |
||||
const match = params.path.match(/^([^\/]+)\/([^\/]+)\.git\/(.+)$/); |
||||
if (!match) return new Response('Invalid path', { status: 400 }); |
||||
const [, npub, repoName, gitPath] = match; |
||||
``` |
||||
|
||||
### 2. Authenticate with NIP-98 |
||||
|
||||
For push operations, verify NIP-98 authentication: |
||||
```typescript |
||||
import { verifyEvent } from 'nostr-tools'; |
||||
|
||||
const authHeader = request.headers.get('Authorization'); |
||||
if (!authHeader?.startsWith('Nostr ')) { |
||||
return new Response('Unauthorized', { status: 401 }); |
||||
} |
||||
|
||||
const nostrEvent = JSON.parse(authHeader.slice(7)); |
||||
if (!verifyEvent(nostrEvent)) { |
||||
return new Response('Invalid signature', { status: 401 }); |
||||
} |
||||
|
||||
// Verify pubkey matches repo owner |
||||
if (nostrEvent.pubkey !== expectedPubkey) { |
||||
return new Response('Unauthorized', { status: 403 }); |
||||
} |
||||
``` |
||||
|
||||
### 3. Map to Git Repository Path |
||||
|
||||
Use `RepoManager` to get the full path: |
||||
```typescript |
||||
import { RepoManager } from '$lib/services/git/repo-manager.js'; |
||||
|
||||
const repoManager = new RepoManager(process.env.GIT_REPO_ROOT || '/repos'); |
||||
const repoPath = join(repoManager.repoRoot, npub, `${repoName}.git`); |
||||
|
||||
if (!repoManager.repoExists(repoPath)) { |
||||
return new Response('Repository not found', { status: 404 }); |
||||
} |
||||
``` |
||||
|
||||
### 4. Proxy to git-http-backend |
||||
|
||||
Execute git-http-backend as a subprocess: |
||||
```typescript |
||||
import { spawn } from 'child_process'; |
||||
import { env } from '$env/dynamic/private'; |
||||
|
||||
const gitHttpBackend = '/usr/lib/git-core/git-http-backend'; // or wherever it's installed |
||||
|
||||
const envVars = { |
||||
...process.env, |
||||
GIT_PROJECT_ROOT: repoManager.repoRoot, |
||||
GIT_HTTP_EXPORT_ALL: '1', |
||||
REQUEST_METHOD: request.method, |
||||
PATH_INFO: `/${npub}/${repoName}.git/${gitPath}`, |
||||
QUERY_STRING: url.searchParams.toString(), |
||||
CONTENT_TYPE: request.headers.get('Content-Type') || '', |
||||
CONTENT_LENGTH: request.headers.get('Content-Length') || '0', |
||||
}; |
||||
|
||||
const gitProcess = spawn(gitHttpBackend, [], { |
||||
env: envVars, |
||||
stdio: ['pipe', 'pipe', 'pipe'] |
||||
}); |
||||
|
||||
// Pipe request body to git-http-backend |
||||
if (request.body) { |
||||
request.body.pipeTo(gitProcess.stdin); |
||||
} |
||||
|
||||
// Return git-http-backend response |
||||
return new Response(gitProcess.stdout, { |
||||
headers: { |
||||
'Content-Type': 'application/x-git-upload-pack-result', |
||||
// or 'application/x-git-receive-pack-result' for push |
||||
} |
||||
}); |
||||
``` |
||||
|
||||
### 5. Post-Receive Hook |
||||
|
||||
After successful push, sync to other remotes: |
||||
```typescript |
||||
// After successful git-receive-pack |
||||
if (gitPath === 'git-receive-pack' && request.method === 'POST') { |
||||
// Fetch NIP-34 announcement for this repo |
||||
const announcement = await fetchRepoAnnouncement(npub, repoName); |
||||
if (announcement) { |
||||
const cloneUrls = extractCloneUrls(announcement); |
||||
const otherUrls = cloneUrls.filter(url => !url.includes('git.imwald.eu')); |
||||
await repoManager.syncToRemotes(repoPath, otherUrls); |
||||
} |
||||
} |
||||
``` |
||||
|
||||
## Alternative: Use a Git Server Library |
||||
|
||||
Instead of calling git-http-backend directly, you could use a Node.js git server library: |
||||
|
||||
- `isomorphic-git` with `@isomorphic-git/http-server` |
||||
- `node-git-server` |
||||
- Custom implementation using `dugite` or `simple-git` |
||||
|
||||
## Testing |
||||
|
||||
Test with: |
||||
```bash |
||||
# Clone |
||||
git clone https://git.imwald.eu/{npub}/{repo-name}.git |
||||
|
||||
# Push (requires NIP-98 auth) |
||||
git push origin main |
||||
``` |
||||
|
||||
For NIP-98 authentication, you'll need a git credential helper that: |
||||
1. Intercepts git HTTP requests |
||||
2. Signs a Nostr event with the user's key |
||||
3. Adds `Authorization: Nostr {event}` header |
||||
@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
|
||||
# gitrepublic-web |
||||
|
||||
A Nostr-based git server with NIP-34 repo announcements. Uses git-http-backend for git operations and provides a web interface for managing repositories. |
||||
|
||||
## Features |
||||
|
||||
- **NIP-34 Repo Announcements**: Display and manage repository announcements |
||||
- **NIP-07 Authentication**: Sign up and authenticate using browser extensions |
||||
- **Auto-provisioning**: Automatically create git repos from NIP-34 announcements |
||||
- **Multi-remote Sync**: Sync repos to multiple remotes listed in announcements |
||||
- **URL Structure**: `git.imwald.eu/{npub}/{repo-name}.git` |
||||
- **User Relay Discovery**: Automatically fetches user's inbox/outbox relays from kind 10002 or 3 |
||||
|
||||
## Development |
||||
|
||||
```bash |
||||
npm install |
||||
npm run dev |
||||
``` |
||||
|
||||
## Environment Variables |
||||
|
||||
- `NOSTRGIT_SECRET_KEY`: Server's nsec for signing repo announcements (optional, for server-side signing) |
||||
- `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) |
||||
- `GIT_DOMAIN`: Domain for git repositories (default: `git.imwald.eu`) |
||||
- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io`) |
||||
|
||||
## Architecture |
||||
|
||||
- **Frontend**: SvelteKit + TypeScript |
||||
- **Git Server**: git-http-backend wrapper (TODO: implement in `/src/routes/api/git/[...path]/+server.ts`) |
||||
- **Authentication**: NIP-07 (browser extension) for web UI, NIP-98 (HTTP auth) for git operations |
||||
- **Discovery**: NIP-34 repo announcements with automatic polling and provisioning |
||||
|
||||
## Project Structure |
||||
|
||||
``` |
||||
src/ |
||||
├── lib/ |
||||
│ ├── services/ |
||||
│ │ ├── nostr/ |
||||
│ │ │ ├── nostr-client.ts # WebSocket client for Nostr relays |
||||
│ │ │ ├── nip07-signer.ts # NIP-07 browser extension integration |
||||
│ │ │ ├── nip19-utils.ts # Decode hex/nevent/naddr addresses |
||||
│ │ │ ├── repo-polling.ts # Auto-provision repos from announcements |
||||
│ │ │ └── user-relays.ts # Fetch user's preferred relays |
||||
│ │ └── git/ |
||||
│ │ └── repo-manager.ts # Server-side repo provisioning & syncing |
||||
│ └── types/ |
||||
│ └── nostr.ts # TypeScript types for Nostr |
||||
├── routes/ |
||||
│ ├── +page.svelte # Main page: list repos on server |
||||
│ ├── signup/ |
||||
│ │ └── +page.svelte # Sign-up: create/update repo announcements |
||||
│ └── api/ |
||||
│ └── git/ |
||||
│ └── [...path]/ |
||||
│ └── +server.ts # Git HTTP backend API (TODO) |
||||
└── hooks.server.ts # Server initialization (starts polling) |
||||
``` |
||||
|
||||
## Implementation Status |
||||
|
||||
✅ **Completed:** |
||||
- NIP-07 authentication for sign-up page |
||||
- Repo announcement display page |
||||
- Repo announcement creation/update with hex/nevent/naddr support |
||||
- User relay discovery (kind 10002 and 3) |
||||
- NIP-34 polling and auto-provisioning service |
||||
- Server-side repo manager for provisioning and syncing |
||||
|
||||
🚧 **In Progress:** |
||||
- Git HTTP backend wrapper with Nostr authentication (NIP-98) |
||||
|
||||
## Next Steps |
||||
|
||||
1. **Implement git-http-backend integration** in `/src/routes/api/git/[...path]/+server.ts`: |
||||
- Parse URL path to extract `{npub}/{repo-name}` |
||||
- Authenticate using NIP-98 (HTTP Authorization header with Nostr event) |
||||
- Proxy requests to `git-http-backend` CGI script |
||||
- Handle git smart HTTP protocol (info/refs, git-upload-pack, git-receive-pack) |
||||
- Trigger post-receive hooks to sync to other remotes |
||||
|
||||
2. **Set up git-http-backend**: |
||||
- Install `git-http-backend` (usually comes with git) |
||||
- Configure as CGI script or FastCGI |
||||
- Set up proper permissions for repo directory |
||||
|
||||
3. **Implement NIP-98 authentication**: |
||||
- Verify Nostr event signature in Authorization header |
||||
- Check that pubkey matches repo owner (from URL) |
||||
- Validate event timestamp (not too old) |
||||
|
||||
4. **Add post-receive hook**: |
||||
- After successful push, extract other clone URLs from NIP-34 announcement |
||||
- Sync to all other remotes using `git push --all` |
||||
|
||||
## Usage |
||||
|
||||
1. **Create a repository announcement:** |
||||
- Go to `/signup` |
||||
- Connect your NIP-07 extension |
||||
- Enter repository name and description |
||||
- Optionally load an existing announcement by providing hex ID, nevent, or naddr |
||||
- Add clone URLs (git.imwald.eu will be added automatically) |
||||
- Publish the announcement |
||||
|
||||
2. **View repositories:** |
||||
- Go to `/` to see all repositories on git.imwald.eu |
||||
- Repositories are automatically provisioned when announcements are published |
||||
|
||||
3. **Clone a repository:** |
||||
```bash |
||||
git clone https://git.imwald.eu/{npub}/{repo-name}.git |
||||
``` |
||||
|
||||
4. **Push to repository:** |
||||
```bash |
||||
git remote add origin https://git.imwald.eu/{npub}/{repo-name}.git |
||||
git push origin main |
||||
``` |
||||
(Requires NIP-98 authentication - TODO) |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
{ |
||||
"name": "gitrepublic-web", |
||||
"version": "0.1.0", |
||||
"type": "module", |
||||
"description": "Nostr-based git server with NIP-34 repo announcements", |
||||
"scripts": { |
||||
"dev": "vite dev", |
||||
"build": "tsc && vite build", |
||||
"preview": "vite preview", |
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", |
||||
"lint": "prettier --check . && eslint .", |
||||
"format": "prettier --write ." |
||||
}, |
||||
"dependencies": { |
||||
"@sveltejs/kit": "^2.0.0", |
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0", |
||||
"nostr-tools": "^2.22.1", |
||||
"svelte": "^5.0.0" |
||||
}, |
||||
"devDependencies": { |
||||
"@sveltejs/adapter-node": "^5.0.0", |
||||
"@types/node": "^20.0.0", |
||||
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||
"@typescript-eslint/parser": "^6.0.0", |
||||
"eslint": "^8.0.0", |
||||
"prettier": "^3.0.0", |
||||
"svelte-check": "^3.0.0", |
||||
"typescript": "^5.0.0", |
||||
"vite": "^5.0.0" |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these types
|
||||
declare global { |
||||
namespace App { |
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
} |
||||
} |
||||
|
||||
export {}; |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="utf-8" /> |
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
||||
%sveltekit.head% |
||||
</head> |
||||
<body data-sveltekit-preload-data="hover"> |
||||
<div style="display: contents">%sveltekit.body%</div> |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
/** |
||||
* Server-side hooks for gitrepublic-web |
||||
* Initializes repo polling service |
||||
*/ |
||||
|
||||
import type { Handle } from '@sveltejs/kit'; |
||||
import { RepoPollingService } from './lib/services/nostr/repo-polling.js'; |
||||
import { GIT_DOMAIN } from './lib/config.js'; |
||||
|
||||
// Initialize polling service
|
||||
const relays = (process.env.NOSTR_RELAYS || 'wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io').split(','); |
||||
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
||||
const domain = GIT_DOMAIN; |
||||
|
||||
let pollingService: RepoPollingService | null = null; |
||||
|
||||
if (typeof process !== 'undefined') { |
||||
pollingService = new RepoPollingService(relays, repoRoot, domain); |
||||
pollingService.start(); |
||||
console.log('Started repo polling service'); |
||||
} |
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => { |
||||
return resolve(event); |
||||
}; |
||||
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
/** |
||||
* Application configuration |
||||
* Centralized config that can be overridden by environment variables |
||||
*/ |
||||
|
||||
/** |
||||
* Git domain for repository URLs |
||||
* Defaults to localhost:6543, can be overridden by GIT_DOMAIN env var |
||||
*/ |
||||
export const GIT_DOMAIN =
|
||||
typeof process !== 'undefined' && process.env?.GIT_DOMAIN |
||||
? process.env.GIT_DOMAIN |
||||
: 'localhost:6543'; |
||||
|
||||
/** |
||||
* Get the full git URL for a repository |
||||
*/ |
||||
export function getGitUrl(npub: string, repoName: string): string { |
||||
const protocol = GIT_DOMAIN.startsWith('localhost') ? 'http' : 'https'; |
||||
return `${protocol}://${GIT_DOMAIN}/${npub}/${repoName}.git`; |
||||
} |
||||
@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
|
||||
/** |
||||
* Repository manager for git repositories |
||||
* Handles repo provisioning, syncing, and NIP-34 integration |
||||
*/ |
||||
|
||||
import { exec } from 'child_process'; |
||||
import { promisify } from 'util'; |
||||
import { existsSync, mkdirSync } from 'fs'; |
||||
import { join } from 'path'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { GIT_DOMAIN } from '../../config.js'; |
||||
|
||||
const execAsync = promisify(exec); |
||||
|
||||
export interface RepoPath { |
||||
npub: string; |
||||
repoName: string; |
||||
fullPath: string; |
||||
} |
||||
|
||||
export class RepoManager { |
||||
private repoRoot: string; |
||||
private domain: string; |
||||
|
||||
constructor(repoRoot: string = '/repos', domain: string = GIT_DOMAIN) { |
||||
this.repoRoot = repoRoot; |
||||
this.domain = domain; |
||||
} |
||||
|
||||
/** |
||||
* Parse git domain URL to extract npub and repo name |
||||
*/ |
||||
parseRepoUrl(url: string): RepoPath | null { |
||||
// Match: https://{domain}/{npub}/{repo-name}.git or http://{domain}/{npub}/{repo-name}.git
|
||||
// Escape domain for regex (replace dots with \.)
|
||||
const escapedDomain = this.domain.replace(/\./g, '\\.'); |
||||
const match = url.match(new RegExp(`${escapedDomain}\\/(npub[a-z0-9]+)\\/([^\\/]+)\\.git`)); |
||||
if (!match) return null; |
||||
|
||||
const [, npub, repoName] = match; |
||||
const fullPath = join(this.repoRoot, npub, `${repoName}.git`); |
||||
|
||||
return { npub, repoName, fullPath }; |
||||
} |
||||
|
||||
/** |
||||
* Create a bare git repository from a NIP-34 repo announcement |
||||
*/ |
||||
async provisionRepo(event: NostrEvent): Promise<void> { |
||||
const cloneUrls = this.extractCloneUrls(event); |
||||
const domainUrl = cloneUrls.find(url => url.includes(this.domain)); |
||||
|
||||
if (!domainUrl) { |
||||
throw new Error(`No ${this.domain} URL found in repo announcement`); |
||||
} |
||||
|
||||
const repoPath = this.parseRepoUrl(domainUrl); |
||||
if (!repoPath) { |
||||
throw new Error(`Invalid ${this.domain} URL format`); |
||||
} |
||||
|
||||
// Create directory structure
|
||||
const repoDir = join(this.repoRoot, repoPath.npub); |
||||
if (!existsSync(repoDir)) { |
||||
mkdirSync(repoDir, { recursive: true }); |
||||
} |
||||
|
||||
// Create bare repository if it doesn't exist
|
||||
if (!existsSync(repoPath.fullPath)) { |
||||
await execAsync(`git init --bare "${repoPath.fullPath}"`); |
||||
} |
||||
|
||||
// If there are other clone URLs, sync from them
|
||||
const otherUrls = cloneUrls.filter(url => !url.includes(this.domain)); |
||||
if (otherUrls.length > 0) { |
||||
await this.syncFromRemotes(repoPath.fullPath, otherUrls); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Sync repository from multiple remote URLs |
||||
*/ |
||||
async syncFromRemotes(repoPath: string, remoteUrls: string[]): Promise<void> { |
||||
for (const url of remoteUrls) { |
||||
try { |
||||
// Add remote if not exists
|
||||
const remoteName = `remote-${remoteUrls.indexOf(url)}`; |
||||
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); |
||||
|
||||
// Fetch from remote
|
||||
await execAsync(`cd "${repoPath}" && git fetch ${remoteName} --all`); |
||||
|
||||
// Update all branches
|
||||
await execAsync(`cd "${repoPath}" && git remote set-head ${remoteName} -a`); |
||||
} catch (error) { |
||||
console.error(`Failed to sync from ${url}:`, error); |
||||
// Continue with other remotes
|
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Sync repository to multiple remote URLs after a push |
||||
*/ |
||||
async syncToRemotes(repoPath: string, remoteUrls: string[]): Promise<void> { |
||||
for (const url of remoteUrls) { |
||||
try { |
||||
const remoteName = `remote-${remoteUrls.indexOf(url)}`; |
||||
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); |
||||
await execAsync(`cd "${repoPath}" && git push ${remoteName} --all --force`); |
||||
await execAsync(`cd "${repoPath}" && git push ${remoteName} --tags --force`); |
||||
} catch (error) { |
||||
console.error(`Failed to sync to ${url}:`, error); |
||||
// Continue with other remotes
|
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Extract clone URLs from a NIP-34 repo announcement |
||||
*/ |
||||
private extractCloneUrls(event: NostrEvent): string[] { |
||||
const urls: string[] = []; |
||||
|
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'clone') { |
||||
for (let i = 1; i < tag.length; i++) { |
||||
const url = tag[i]; |
||||
if (url && typeof url === 'string') { |
||||
urls.push(url); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return urls; |
||||
} |
||||
|
||||
/** |
||||
* Check if a repository exists |
||||
*/ |
||||
repoExists(repoPath: string): boolean { |
||||
return existsSync(repoPath); |
||||
} |
||||
} |
||||
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
/** |
||||
* NIP-07 browser extension signer |
||||
*/ |
||||
|
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
declare global { |
||||
interface Window { |
||||
nostr?: { |
||||
getPublicKey(): Promise<string>; |
||||
signEvent(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent>; |
||||
}; |
||||
} |
||||
} |
||||
|
||||
export function isNIP07Available(): boolean { |
||||
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'; |
||||
} |
||||
|
||||
export async function getPublicKeyWithNIP07(): Promise<string> { |
||||
if (!isNIP07Available()) { |
||||
throw new Error('NIP-07 extension not available'); |
||||
} |
||||
return await window.nostr!.getPublicKey(); |
||||
} |
||||
|
||||
export async function signEventWithNIP07(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent> { |
||||
if (!isNIP07Available()) { |
||||
throw new Error('NIP-07 extension not available'); |
||||
} |
||||
return await window.nostr!.signEvent(event); |
||||
} |
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
/** |
||||
* NIP-19 utilities for decoding bech32 addresses |
||||
*/ |
||||
|
||||
import { nip19 } from 'nostr-tools'; |
||||
|
||||
export interface DecodedEvent { |
||||
type: 'nevent' | 'naddr' | 'note'; |
||||
id?: string; |
||||
pubkey?: string; |
||||
kind?: number; |
||||
identifier?: string; |
||||
relays?: string[]; |
||||
} |
||||
|
||||
export function decodeNostrAddress(input: string): DecodedEvent | null { |
||||
if (!input || input.trim() === '') return null; |
||||
|
||||
const trimmed = input.trim(); |
||||
|
||||
// If it's already hex (64 chars), treat as event ID
|
||||
if (/^[a-f0-9]{64}$/i.test(trimmed)) { |
||||
return { type: 'note', id: trimmed.toLowerCase() }; |
||||
} |
||||
|
||||
try { |
||||
const decoded = nip19.decode(trimmed); |
||||
|
||||
if (decoded.type === 'nevent') { |
||||
const data = decoded.data as { id: string; pubkey?: string; relays?: string[] }; |
||||
return { |
||||
type: 'nevent', |
||||
id: data.id, |
||||
pubkey: data.pubkey, |
||||
relays: data.relays |
||||
}; |
||||
} else if (decoded.type === 'naddr') { |
||||
const data = decoded.data as { pubkey: string; kind: number; identifier: string; relays?: string[] }; |
||||
return { |
||||
type: 'naddr', |
||||
pubkey: data.pubkey, |
||||
kind: data.kind, |
||||
identifier: data.identifier, |
||||
relays: data.relays |
||||
}; |
||||
} else if (decoded.type === 'note') { |
||||
return { |
||||
type: 'note', |
||||
id: decoded.data as string |
||||
}; |
||||
} |
||||
} catch (e) { |
||||
// Not a valid bech32
|
||||
return null; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
|
||||
/** |
||||
* Nostr client for fetching and publishing events |
||||
*/ |
||||
|
||||
import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; |
||||
|
||||
export class NostrClient { |
||||
private relays: string[] = []; |
||||
|
||||
constructor(relays: string[]) { |
||||
this.relays = relays; |
||||
} |
||||
|
||||
async fetchEvents(filters: NostrFilter[]): Promise<NostrEvent[]> { |
||||
const events: NostrEvent[] = []; |
||||
|
||||
// Fetch from all relays in parallel
|
||||
const promises = this.relays.map(relay => this.fetchFromRelay(relay, filters)); |
||||
const results = await Promise.allSettled(promises); |
||||
|
||||
for (const result of results) { |
||||
if (result.status === 'fulfilled') { |
||||
events.push(...result.value); |
||||
} |
||||
} |
||||
|
||||
// Deduplicate by event ID
|
||||
const uniqueEvents = new Map<string, NostrEvent>(); |
||||
for (const event of events) { |
||||
if (!uniqueEvents.has(event.id) || event.created_at > uniqueEvents.get(event.id)!.created_at) { |
||||
uniqueEvents.set(event.id, event); |
||||
} |
||||
} |
||||
|
||||
return Array.from(uniqueEvents.values()); |
||||
} |
||||
|
||||
private async fetchFromRelay(relay: string, filters: NostrFilter[]): Promise<NostrEvent[]> { |
||||
return new Promise((resolve, reject) => { |
||||
const ws = new WebSocket(relay); |
||||
const events: NostrEvent[] = []; |
||||
|
||||
ws.onopen = () => { |
||||
ws.send(JSON.stringify(['REQ', 'sub', ...filters])); |
||||
}; |
||||
|
||||
ws.onmessage = (event: MessageEvent) => { |
||||
const message = JSON.parse(event.data); |
||||
|
||||
if (message[0] === 'EVENT') { |
||||
events.push(message[2]); |
||||
} else if (message[0] === 'EOSE') { |
||||
ws.close(); |
||||
resolve(events); |
||||
} |
||||
}; |
||||
|
||||
ws.onerror = (error) => { |
||||
ws.close(); |
||||
reject(error); |
||||
}; |
||||
|
||||
setTimeout(() => { |
||||
ws.close(); |
||||
resolve(events); |
||||
}, 5000); |
||||
}); |
||||
} |
||||
|
||||
async publishEvent(event: NostrEvent, relays?: string[]): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> { |
||||
const targetRelays = relays || this.relays; |
||||
const success: string[] = []; |
||||
const failed: Array<{ relay: string; error: string }> = []; |
||||
|
||||
const promises = targetRelays.map(async (relay) => { |
||||
try { |
||||
await this.publishToRelay(relay, event); |
||||
success.push(relay); |
||||
} catch (error) { |
||||
failed.push({ relay, error: String(error) }); |
||||
} |
||||
}); |
||||
|
||||
await Promise.allSettled(promises); |
||||
|
||||
return { success, failed }; |
||||
} |
||||
|
||||
private async publishToRelay(relay: string, nostrEvent: NostrEvent): Promise<void> { |
||||
return new Promise((resolve, reject) => { |
||||
const ws = new WebSocket(relay); |
||||
|
||||
ws.onopen = () => { |
||||
ws.send(JSON.stringify(['EVENT', nostrEvent])); |
||||
}; |
||||
|
||||
ws.onmessage = (event: MessageEvent) => { |
||||
const message = JSON.parse(event.data); |
||||
|
||||
if (message[0] === 'OK' && message[1] === nostrEvent.id) { |
||||
if (message[2] === true) { |
||||
ws.close(); |
||||
resolve(); |
||||
} else { |
||||
ws.close(); |
||||
reject(new Error(message[3] || 'Publish rejected')); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
ws.onerror = (error) => { |
||||
ws.close(); |
||||
reject(error); |
||||
}; |
||||
|
||||
setTimeout(() => { |
||||
ws.close(); |
||||
reject(new Error('Timeout')); |
||||
}, 5000); |
||||
}); |
||||
} |
||||
} |
||||
@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
|
||||
/** |
||||
* Service for polling NIP-34 repo announcements and auto-provisioning repos |
||||
*/ |
||||
|
||||
import { NostrClient } from './nostr-client.js'; |
||||
import { KIND } from '../../types/nostr.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { RepoManager } from '../git/repo-manager.js'; |
||||
|
||||
export class RepoPollingService { |
||||
private nostrClient: NostrClient; |
||||
private repoManager: RepoManager; |
||||
private pollingInterval: number; |
||||
private intervalId: NodeJS.Timeout | null = null; |
||||
private domain: string; |
||||
|
||||
constructor( |
||||
relays: string[], |
||||
repoRoot: string, |
||||
domain: string, |
||||
pollingInterval: number = 60000 // 1 minute
|
||||
) { |
||||
this.nostrClient = new NostrClient(relays); |
||||
this.repoManager = new RepoManager(repoRoot, domain); |
||||
this.pollingInterval = pollingInterval; |
||||
this.domain = domain; |
||||
} |
||||
|
||||
/** |
||||
* Start polling for repo announcements |
||||
*/ |
||||
start(): void { |
||||
if (this.intervalId) { |
||||
this.stop(); |
||||
} |
||||
|
||||
// Poll immediately
|
||||
this.poll(); |
||||
|
||||
// Then poll at intervals
|
||||
this.intervalId = setInterval(() => { |
||||
this.poll(); |
||||
}, this.pollingInterval); |
||||
} |
||||
|
||||
/** |
||||
* Stop polling |
||||
*/ |
||||
stop(): void { |
||||
if (this.intervalId) { |
||||
clearInterval(this.intervalId); |
||||
this.intervalId = null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Poll for new repo announcements and provision repos |
||||
*/ |
||||
private async poll(): Promise<void> { |
||||
try { |
||||
const events = await this.nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||
limit: 100 |
||||
} |
||||
]); |
||||
|
||||
// Filter for repos that list our domain
|
||||
const relevantEvents = events.filter(event => { |
||||
const cloneUrls = this.extractCloneUrls(event); |
||||
return cloneUrls.some(url => url.includes(this.domain)); |
||||
}); |
||||
|
||||
// Provision each repo
|
||||
for (const event of relevantEvents) { |
||||
try { |
||||
await this.repoManager.provisionRepo(event); |
||||
console.log(`Provisioned repo from announcement ${event.id}`); |
||||
} catch (error) { |
||||
console.error(`Failed to provision repo from ${event.id}:`, error); |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.error('Error polling for repo announcements:', error); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Extract clone URLs from a NIP-34 repo announcement |
||||
*/ |
||||
private extractCloneUrls(event: NostrEvent): string[] { |
||||
const urls: string[] = []; |
||||
|
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'clone') { |
||||
for (let i = 1; i < tag.length; i++) { |
||||
const url = tag[i]; |
||||
if (url && typeof url === 'string') { |
||||
urls.push(url); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return urls; |
||||
} |
||||
} |
||||
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
/** |
||||
* Service for fetching user's preferred relays from their inbox/outbox |
||||
*/ |
||||
|
||||
import { NostrClient } from './nostr-client.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
const KIND_RELAY_LIST = 10002; |
||||
const KIND_CONTACTS = 3; |
||||
|
||||
export async function getUserRelays( |
||||
pubkey: string, |
||||
nostrClient: NostrClient |
||||
): Promise<{ inbox: string[]; outbox: string[] }> { |
||||
const inbox: string[] = []; |
||||
const outbox: string[] = []; |
||||
|
||||
try { |
||||
// Fetch kind 10002 (relay list)
|
||||
const relayListEvents = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND_RELAY_LIST], |
||||
authors: [pubkey], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (relayListEvents.length > 0) { |
||||
const event = relayListEvents[0]; |
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'relay' && tag[1]) { |
||||
const relay = tag[1]; |
||||
const read = tag[2] !== 'write'; |
||||
const write = tag[2] !== 'read'; |
||||
|
||||
if (read) inbox.push(relay); |
||||
if (write) outbox.push(relay); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Fallback to kind 3 (contacts) for older clients
|
||||
if (inbox.length === 0 && outbox.length === 0) { |
||||
const contactEvents = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND_CONTACTS], |
||||
authors: [pubkey], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (contactEvents.length > 0) { |
||||
const event = contactEvents[0]; |
||||
// Extract relays from content (JSON) or tags
|
||||
try { |
||||
const content = JSON.parse(event.content); |
||||
if (content.relays && Array.isArray(content.relays)) { |
||||
inbox.push(...content.relays); |
||||
outbox.push(...content.relays); |
||||
} |
||||
} catch { |
||||
// Not JSON, check tags
|
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'relay' && tag[1]) { |
||||
inbox.push(tag[1]); |
||||
outbox.push(tag[1]); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} catch (error) { |
||||
console.error('Failed to fetch user relays:', error); |
||||
} |
||||
|
||||
return { inbox, outbox }; |
||||
} |
||||
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
/** |
||||
* Nostr type definitions |
||||
*/ |
||||
|
||||
export interface NostrEvent { |
||||
id: string; |
||||
pubkey: string; |
||||
created_at: number; |
||||
kind: number; |
||||
tags: string[][]; |
||||
content: string; |
||||
sig: string; |
||||
} |
||||
|
||||
export interface NostrFilter { |
||||
ids?: string[]; |
||||
authors?: string[]; |
||||
kinds?: number[]; |
||||
'#e'?: string[]; |
||||
'#p'?: string[]; |
||||
'#d'?: string[]; |
||||
since?: number; |
||||
until?: number; |
||||
limit?: number; |
||||
} |
||||
|
||||
export const KIND = { |
||||
REPO_ANNOUNCEMENT: 30617, |
||||
} as const; |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
/** |
||||
* Root layout server load function |
||||
* Provides config to all pages |
||||
*/ |
||||
|
||||
import { GIT_DOMAIN } from '$lib/config.js'; |
||||
|
||||
export async function load() { |
||||
return { |
||||
gitDomain: GIT_DOMAIN |
||||
}; |
||||
} |
||||
@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
import { page } from '$app/stores'; |
||||
import { NostrClient } from '../lib/services/nostr/nostr-client.js'; |
||||
import { KIND } from '../lib/types/nostr.js'; |
||||
import type { NostrEvent } from '../lib/types/nostr.js'; |
||||
|
||||
let repos = $state<NostrEvent[]>([]); |
||||
let loading = $state(true); |
||||
let error = $state<string | null>(null); |
||||
|
||||
const relays = [ |
||||
'wss://theforest.nostr1.com', |
||||
'wss://nostr.land', |
||||
'wss://relay.damus.io' |
||||
]; |
||||
|
||||
const nostrClient = new NostrClient(relays); |
||||
|
||||
onMount(async () => { |
||||
await loadRepos(); |
||||
}); |
||||
|
||||
async function loadRepos() { |
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const events = await nostrClient.fetchEvents([ |
||||
{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 } |
||||
]); |
||||
|
||||
// Get git domain from layout data |
||||
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
||||
|
||||
// Filter for repos that list our domain in clone tags |
||||
repos = events.filter(event => { |
||||
const cloneUrls = event.tags |
||||
.filter(t => t[0] === 'clone') |
||||
.flatMap(t => t.slice(1)) |
||||
.filter(url => url && typeof url === 'string'); |
||||
|
||||
return cloneUrls.some(url => url.includes(gitDomain)); |
||||
}); |
||||
|
||||
// Sort by created_at descending |
||||
repos.sort((a, b) => b.created_at - a.created_at); |
||||
} catch (e) { |
||||
error = String(e); |
||||
console.error('Failed to load repos:', e); |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
function getRepoName(event: NostrEvent): string { |
||||
const nameTag = event.tags.find(t => t[0] === 'name' && t[1]); |
||||
if (nameTag?.[1]) return nameTag[1]; |
||||
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
||||
if (dTag) return dTag; |
||||
|
||||
return `Repository ${event.id.slice(0, 8)}`; |
||||
} |
||||
|
||||
function getRepoDescription(event: NostrEvent): string { |
||||
const descTag = event.tags.find(t => t[0] === 'description' && t[1]); |
||||
return descTag?.[1] || ''; |
||||
} |
||||
|
||||
function getCloneUrls(event: NostrEvent): string[] { |
||||
const urls: string[] = []; |
||||
|
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'clone') { |
||||
for (let i = 1; i < tag.length; i++) { |
||||
const url = tag[i]; |
||||
if (url && typeof url === 'string') { |
||||
urls.push(url); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return urls; |
||||
} |
||||
</script> |
||||
|
||||
<div class="container"> |
||||
<header> |
||||
<h1>gitrepublic</h1> |
||||
<nav> |
||||
<a href="/">Repositories</a> |
||||
<a href="/signup">Sign Up</a> |
||||
</nav> |
||||
</header> |
||||
|
||||
<main> |
||||
<div class="repos-header"> |
||||
<h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2> |
||||
<button onclick={loadRepos} disabled={loading}> |
||||
{loading ? 'Loading...' : 'Refresh'} |
||||
</button> |
||||
</div> |
||||
|
||||
{#if error} |
||||
<div class="error"> |
||||
Error loading repositories: {error} |
||||
</div> |
||||
{:else if loading} |
||||
<div class="loading">Loading repositories...</div> |
||||
{:else if repos.length === 0} |
||||
<div class="empty">No repositories found.</div> |
||||
{:else} |
||||
<div class="repos-list"> |
||||
{#each repos as repo} |
||||
<div class="repo-card"> |
||||
<h3>{getRepoName(repo)}</h3> |
||||
{#if getRepoDescription(repo)} |
||||
<p class="description">{getRepoDescription(repo)}</p> |
||||
{/if} |
||||
<div class="clone-urls"> |
||||
<strong>Clone URLs:</strong> |
||||
{#each getCloneUrls(repo) as url} |
||||
<code>{url}</code> |
||||
{/each} |
||||
</div> |
||||
<div class="repo-meta"> |
||||
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
</div> |
||||
|
||||
<style> |
||||
.container { |
||||
max-width: 1200px; |
||||
margin: 0 auto; |
||||
padding: 2rem; |
||||
} |
||||
|
||||
header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 2rem; |
||||
border-bottom: 1px solid #e5e7eb; |
||||
padding-bottom: 1rem; |
||||
} |
||||
|
||||
nav { |
||||
display: flex; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
nav a { |
||||
text-decoration: none; |
||||
color: #3b82f6; |
||||
} |
||||
|
||||
.repos-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.repos-list { |
||||
display: grid; |
||||
gap: 1.5rem; |
||||
} |
||||
|
||||
.repo-card { |
||||
border: 1px solid #e5e7eb; |
||||
border-radius: 0.5rem; |
||||
padding: 1.5rem; |
||||
background: white; |
||||
} |
||||
|
||||
.repo-card h3 { |
||||
margin: 0 0 0.5rem 0; |
||||
font-size: 1.25rem; |
||||
} |
||||
|
||||
.description { |
||||
color: #6b7280; |
||||
margin: 0.5rem 0; |
||||
} |
||||
|
||||
.clone-urls { |
||||
margin: 1rem 0; |
||||
} |
||||
|
||||
.clone-urls code { |
||||
display: block; |
||||
background: #f3f4f6; |
||||
padding: 0.5rem; |
||||
border-radius: 0.25rem; |
||||
margin: 0.25rem 0; |
||||
font-family: monospace; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.repo-meta { |
||||
font-size: 0.875rem; |
||||
color: #6b7280; |
||||
margin-top: 1rem; |
||||
} |
||||
|
||||
.error { |
||||
background: #fee2e2; |
||||
color: #991b1b; |
||||
padding: 1rem; |
||||
border-radius: 0.5rem; |
||||
margin: 1rem 0; |
||||
} |
||||
|
||||
.loading, .empty { |
||||
text-align: center; |
||||
padding: 2rem; |
||||
color: #6b7280; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
/** |
||||
* Git HTTP backend API route |
||||
* Handles git clone, push, pull operations via git-http-backend |
||||
*/ |
||||
|
||||
import { json } from '@sveltejs/kit'; |
||||
import type { RequestHandler } from './$types'; |
||||
|
||||
// This will be implemented to proxy requests to git-http-backend
|
||||
// For now, return a placeholder
|
||||
export const GET: RequestHandler = async ({ params, url }) => { |
||||
const path = params.path || ''; |
||||
const service = url.searchParams.get('service'); |
||||
|
||||
// TODO: Implement git-http-backend integration
|
||||
// This should:
|
||||
// 1. Authenticate using NIP-98 (HTTP auth with Nostr)
|
||||
// 2. Map URL path to git repo ({domain}/{npub}/{repo-name}.git)
|
||||
// 3. Proxy request to git-http-backend
|
||||
// 4. Handle git smart HTTP protocol
|
||||
|
||||
return json({
|
||||
message: 'Git HTTP backend not yet implemented', |
||||
path, |
||||
service
|
||||
}); |
||||
}; |
||||
|
||||
export const POST: RequestHandler = async ({ params, url, request }) => { |
||||
const path = params.path || ''; |
||||
const service = url.searchParams.get('service'); |
||||
|
||||
// TODO: Implement git-http-backend integration for push operations
|
||||
|
||||
return json({
|
||||
message: 'Git HTTP backend not yet implemented', |
||||
path, |
||||
service
|
||||
}); |
||||
}; |
||||
@ -0,0 +1,475 @@
@@ -0,0 +1,475 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
import { page } from '$app/stores'; |
||||
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from '../../lib/services/nostr/nip07-signer.js'; |
||||
import { decodeNostrAddress } from '../../lib/services/nostr/nip19-utils.js'; |
||||
import { NostrClient } from '../../lib/services/nostr/nostr-client.js'; |
||||
import { KIND } from '../../lib/types/nostr.js'; |
||||
import type { NostrEvent } from '../../lib/types/nostr.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
|
||||
let nip07Available = $state(false); |
||||
let loading = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let success = $state(false); |
||||
|
||||
// Form fields |
||||
let repoName = $state(''); |
||||
let description = $state(''); |
||||
let cloneUrls = $state<string[]>(['']); |
||||
let existingRepoRef = $state(''); // hex, nevent, or naddr |
||||
let loadingExisting = $state(false); |
||||
|
||||
const relays = [ |
||||
'wss://theforest.nostr1.com', |
||||
'wss://nostr.land', |
||||
'wss://relay.damus.io' |
||||
]; |
||||
|
||||
const nostrClient = new NostrClient(relays); |
||||
|
||||
onMount(() => { |
||||
nip07Available = isNIP07Available(); |
||||
}); |
||||
|
||||
function addCloneUrl() { |
||||
cloneUrls = [...cloneUrls, '']; |
||||
} |
||||
|
||||
function removeCloneUrl(index: number) { |
||||
cloneUrls = cloneUrls.filter((_, i) => i !== index); |
||||
} |
||||
|
||||
function updateCloneUrl(index: number, value: string) { |
||||
const newUrls = [...cloneUrls]; |
||||
newUrls[index] = value; |
||||
cloneUrls = newUrls; |
||||
} |
||||
|
||||
async function loadExistingRepo() { |
||||
if (!existingRepoRef.trim()) return; |
||||
|
||||
loadingExisting = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const decoded = decodeNostrAddress(existingRepoRef.trim()); |
||||
if (!decoded) { |
||||
error = 'Invalid format. Please provide a hex event ID, nevent, or naddr.'; |
||||
loadingExisting = false; |
||||
return; |
||||
} |
||||
|
||||
let event: NostrEvent | null = null; |
||||
|
||||
if (decoded.type === 'note' && decoded.id) { |
||||
// Fetch by event ID |
||||
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]); |
||||
event = events[0] || null; |
||||
} else if (decoded.type === 'nevent' && decoded.id) { |
||||
// Fetch by event ID |
||||
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]); |
||||
event = events[0] || null; |
||||
} else if (decoded.type === 'naddr' && decoded.pubkey && decoded.kind && decoded.identifier) { |
||||
// Fetch parameterized replaceable event |
||||
const events = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [decoded.kind], |
||||
authors: [decoded.pubkey], |
||||
'#d': [decoded.identifier], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
event = events[0] || null; |
||||
} |
||||
|
||||
if (!event) { |
||||
error = 'Repository announcement not found. Make sure it exists on the relays.'; |
||||
loadingExisting = false; |
||||
return; |
||||
} |
||||
|
||||
if (event.kind !== KIND.REPO_ANNOUNCEMENT) { |
||||
error = 'The provided event is not a repository announcement (kind 30617).'; |
||||
loadingExisting = false; |
||||
return; |
||||
} |
||||
|
||||
// Populate form with existing data |
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
||||
const nameTag = event.tags.find(t => t[0] === 'name')?.[1] || ''; |
||||
const descTag = event.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||
|
||||
repoName = nameTag || dTag; |
||||
description = descTag; |
||||
|
||||
// Extract clone URLs |
||||
const urls: string[] = []; |
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'clone') { |
||||
for (let i = 1; i < tag.length; i++) { |
||||
const url = tag[i]; |
||||
if (url && typeof url === 'string') { |
||||
urls.push(url); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
cloneUrls = urls.length > 0 ? urls : ['']; |
||||
|
||||
} catch (e) { |
||||
error = `Failed to load repository: ${String(e)}`; |
||||
} finally { |
||||
loadingExisting = false; |
||||
} |
||||
} |
||||
|
||||
async function submit() { |
||||
if (!nip07Available) { |
||||
error = 'NIP-07 extension is required. Please install a Nostr browser extension.'; |
||||
return; |
||||
} |
||||
|
||||
if (!repoName.trim()) { |
||||
error = 'Repository name is required.'; |
||||
return; |
||||
} |
||||
|
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const pubkey = await getPublicKeyWithNIP07(); |
||||
const npub = nip19.npubEncode(pubkey); |
||||
|
||||
// Normalize repo name to d-tag format |
||||
const dTag = repoName |
||||
.toLowerCase() |
||||
.trim() |
||||
.replace(/[^\w\s-]/g, '') |
||||
.replace(/\s+/g, '-') |
||||
.replace(/-+/g, '-') |
||||
.replace(/^-+|-+$/g, ''); |
||||
|
||||
// Get git domain from layout data |
||||
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
||||
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; |
||||
const gitUrl = `${protocol}://${gitDomain}/${npub}/${dTag}.git`; |
||||
|
||||
// Build clone URLs - always include our domain |
||||
const allCloneUrls = [ |
||||
gitUrl, |
||||
...cloneUrls.filter(url => url.trim() && !url.includes(gitDomain)) |
||||
]; |
||||
|
||||
// Build tags |
||||
const tags: string[][] = [ |
||||
['d', dTag], |
||||
['name', repoName], |
||||
...(description ? [['description', description]] : []), |
||||
...allCloneUrls.map(url => ['clone', url]), |
||||
['relays', ...relays] |
||||
]; |
||||
|
||||
// Build event |
||||
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
||||
kind: KIND.REPO_ANNOUNCEMENT, |
||||
pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', // Empty per NIP-34 |
||||
tags |
||||
}; |
||||
|
||||
// Sign with NIP-07 |
||||
const signedEvent = await signEventWithNIP07(eventTemplate); |
||||
|
||||
// Get user's inbox/outbox relays (from kind 3 or 10002) |
||||
const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js'); |
||||
const { inbox, outbox } = await getUserRelays(pubkey, nostrClient); |
||||
|
||||
// Combine user's outbox with default relays |
||||
const userRelays = [...new Set([...outbox, ...relays])]; |
||||
|
||||
// Publish to user's outboxes and standard relays |
||||
const result = await nostrClient.publishEvent(signedEvent, userRelays); |
||||
|
||||
if (result.success.length > 0) { |
||||
success = true; |
||||
setTimeout(() => { |
||||
goto('/'); |
||||
}, 2000); |
||||
} else { |
||||
error = 'Failed to publish to any relays.'; |
||||
} |
||||
|
||||
} catch (e) { |
||||
error = `Failed to create repository announcement: ${String(e)}`; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="container"> |
||||
<header> |
||||
<h1>gitrepublic</h1> |
||||
<nav> |
||||
<a href="/">Repositories</a> |
||||
<a href="/signup">Sign Up</a> |
||||
</nav> |
||||
</header> |
||||
|
||||
<main> |
||||
<h2>Create or Update Repository Announcement</h2> |
||||
|
||||
{#if !nip07Available} |
||||
<div class="warning"> |
||||
<p>NIP-07 browser extension is required to sign repository announcements.</p> |
||||
<p>Please install a Nostr browser extension (like Alby, nos2x, or similar).</p> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if error} |
||||
<div class="error">{error}</div> |
||||
{/if} |
||||
|
||||
{#if success} |
||||
<div class="success"> |
||||
Repository announcement published successfully! Redirecting... |
||||
</div> |
||||
{/if} |
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); submit(); }}> |
||||
<div class="form-group"> |
||||
<label for="existing-repo-ref"> |
||||
Load Existing Repository (optional) |
||||
<small>Enter hex event ID, nevent, or naddr to update an existing announcement</small> |
||||
</label> |
||||
<div class="input-group"> |
||||
<input |
||||
id="existing-repo-ref" |
||||
type="text" |
||||
bind:value={existingRepoRef} |
||||
placeholder="hex event ID, nevent1..., or naddr1..." |
||||
disabled={loading || loadingExisting} |
||||
/> |
||||
<button |
||||
type="button" |
||||
onclick={loadExistingRepo} |
||||
disabled={loading || loadingExisting || !existingRepoRef.trim()} |
||||
> |
||||
{loadingExisting ? 'Loading...' : 'Load'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="repo-name"> |
||||
Repository Name * |
||||
<small>Will be used as the d-tag (normalized to lowercase with hyphens)</small> |
||||
</label> |
||||
<input |
||||
id="repo-name" |
||||
type="text" |
||||
bind:value={repoName} |
||||
placeholder="my-awesome-repo" |
||||
required |
||||
disabled={loading} |
||||
/> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="description"> |
||||
Description |
||||
</label> |
||||
<textarea |
||||
id="description" |
||||
bind:value={description} |
||||
placeholder="Repository description" |
||||
rows={3} |
||||
disabled={loading} |
||||
/> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label> |
||||
Clone URLs |
||||
<small>{$page.data.gitDomain || 'localhost:6543'} will be added automatically</small> |
||||
</label> |
||||
{#each cloneUrls as url, index} |
||||
<div class="input-group"> |
||||
<input |
||||
type="text" |
||||
value={url} |
||||
oninput={(e) => updateCloneUrl(index, e.currentTarget.value)} |
||||
placeholder="https://github.com/user/repo.git" |
||||
disabled={loading} |
||||
/> |
||||
{#if cloneUrls.length > 1} |
||||
<button |
||||
type="button" |
||||
onclick={() => removeCloneUrl(index)} |
||||
disabled={loading} |
||||
> |
||||
Remove |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
<button |
||||
type="button" |
||||
onclick={addCloneUrl} |
||||
disabled={loading} |
||||
class="add-button" |
||||
> |
||||
+ Add Clone URL |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="form-actions"> |
||||
<button |
||||
type="submit" |
||||
disabled={loading || !nip07Available} |
||||
> |
||||
{loading ? 'Publishing...' : 'Publish Repository Announcement'} |
||||
</button> |
||||
<a href="/" class="cancel-link">Cancel</a> |
||||
</div> |
||||
</form> |
||||
</main> |
||||
</div> |
||||
|
||||
<style> |
||||
.container { |
||||
max-width: 800px; |
||||
margin: 0 auto; |
||||
padding: 2rem; |
||||
} |
||||
|
||||
header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 2rem; |
||||
border-bottom: 1px solid #e5e7eb; |
||||
padding-bottom: 1rem; |
||||
} |
||||
|
||||
nav { |
||||
display: flex; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
nav a { |
||||
text-decoration: none; |
||||
color: #3b82f6; |
||||
} |
||||
|
||||
.warning { |
||||
background: #fef3c7; |
||||
border: 1px solid #f59e0b; |
||||
border-radius: 0.5rem; |
||||
padding: 1rem; |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
.error { |
||||
background: #fee2e2; |
||||
color: #991b1b; |
||||
padding: 1rem; |
||||
border-radius: 0.5rem; |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
.success { |
||||
background: #d1fae5; |
||||
color: #065f46; |
||||
padding: 1rem; |
||||
border-radius: 0.5rem; |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
form { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1.5rem; |
||||
} |
||||
|
||||
.form-group { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
label { |
||||
font-weight: 500; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.25rem; |
||||
} |
||||
|
||||
label small { |
||||
font-weight: normal; |
||||
color: #6b7280; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
input, textarea { |
||||
padding: 0.75rem; |
||||
border: 1px solid #d1d5db; |
||||
border-radius: 0.375rem; |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
input:disabled, textarea:disabled { |
||||
background: #f3f4f6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.input-group { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.input-group input { |
||||
flex: 1; |
||||
} |
||||
|
||||
button { |
||||
padding: 0.5rem 1rem; |
||||
background: #3b82f6; |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.375rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
button:disabled { |
||||
background: #9ca3af; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.add-button { |
||||
background: #10b981; |
||||
margin-top: 0.5rem; |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 1rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.form-actions button[type="submit"] { |
||||
padding: 0.75rem 1.5rem; |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
.cancel-link { |
||||
color: #6b7280; |
||||
text-decoration: none; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node'; |
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; |
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */ |
||||
const config = { |
||||
preprocess: vitePreprocess(), |
||||
kit: { |
||||
adapter: adapter() |
||||
} |
||||
}; |
||||
|
||||
export default config; |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
{ |
||||
"extends": "./.svelte-kit/tsconfig.json", |
||||
"compilerOptions": { |
||||
"allowJs": true, |
||||
"checkJs": true, |
||||
"esModuleInterop": true, |
||||
"forceConsistentCasingInFileNames": true, |
||||
"resolveJsonModule": true, |
||||
"skipLibCheck": true, |
||||
"sourceMap": true, |
||||
"strict": true, |
||||
"moduleResolution": "bundler", |
||||
"target": "ES2022", |
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"] |
||||
} |
||||
} |
||||
Loading…
Reference in new issue