Browse Source
implement transfer on cli and api Nostr-Signature: a312986953d2b408aae10a51ec29b51aca8a2e6396e5b5ec7fd969bb12c5b882 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 09b7bff4ce945ac120a413246a0a6111bf9afc14e570524f1e2e4f8ee8e22a2a2c71fd00fedb836e030245d3cbc1e42fcb8c5bde7c643fde2551582f63942851main
6 changed files with 2 additions and 643 deletions
@ -1,222 +0,0 @@
@@ -1,222 +0,0 @@
|
||||
/** |
||||
* API endpoint for repository settings |
||||
*/ |
||||
|
||||
import { json, error } from '@sveltejs/kit'; |
||||
import type { RequestHandler } from './$types'; |
||||
import { nostrClient, maintainerService, ownershipTransferService, fileManager } from '$lib/services/service-registry.js'; |
||||
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; |
||||
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js'; |
||||
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; |
||||
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; |
||||
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import logger from '$lib/services/logger.js'; |
||||
|
||||
/** |
||||
* GET - Get repository settings |
||||
*/ |
||||
export const GET: RequestHandler = createRepoGetHandler( |
||||
async (context: RepoRequestContext) => { |
||||
// Check if user is owner
|
||||
if (!context.userPubkeyHex) { |
||||
throw handleApiError(new Error('Authentication required'), { operation: 'getSettings', npub: context.npub, repo: context.repo }, 'Authentication required'); |
||||
} |
||||
|
||||
const currentOwner = await ownershipTransferService.getCurrentOwner(context.repoOwnerPubkey, context.repo); |
||||
if (context.userPubkeyHex !== currentOwner) { |
||||
throw handleAuthorizationError('Only the repository owner can access settings', { operation: 'getSettings', npub: context.npub, repo: context.repo }); |
||||
} |
||||
|
||||
// Get repository announcement
|
||||
const events = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||
authors: [currentOwner], |
||||
'#d': [context.repo], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (events.length === 0) { |
||||
throw handleNotFoundError('Repository announcement not found', { operation: 'getSettings', npub: context.npub, repo: context.repo }); |
||||
} |
||||
|
||||
const announcement = events[0]; |
||||
const name = announcement.tags.find(t => t[0] === 'name')?.[1] || context.repo; |
||||
const description = announcement.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||
const cloneUrls = announcement.tags |
||||
.filter(t => t[0] === 'clone') |
||||
.flatMap(t => t.slice(1)) |
||||
.filter(url => url && typeof url === 'string') as string[]; |
||||
const maintainers = announcement.tags |
||||
.filter(t => t[0] === 'maintainers') |
||||
.flatMap(t => t.slice(1)) |
||||
.filter(m => m && typeof m === 'string') as string[]; |
||||
const chatRelays = announcement.tags |
||||
.filter(t => t[0] === 'chat-relay') |
||||
.flatMap(t => t.slice(1)) |
||||
.filter(url => url && typeof url === 'string') as string[]; |
||||
const privacyInfo = await maintainerService.getPrivacyInfo(currentOwner, context.repo); |
||||
const isPrivate = privacyInfo.isPrivate; |
||||
|
||||
return json({ |
||||
name, |
||||
description, |
||||
cloneUrls, |
||||
maintainers, |
||||
chatRelays, |
||||
isPrivate, |
||||
owner: currentOwner, |
||||
npub: context.npub |
||||
}); |
||||
}, |
||||
{ operation: 'getSettings', requireRepoAccess: false } // Override to check owner instead
|
||||
); |
||||
|
||||
/** |
||||
* POST - Update repository settings |
||||
*/ |
||||
export const POST: RequestHandler = withRepoValidation( |
||||
async ({ repoContext, requestContext, event }) => { |
||||
if (!requestContext.userPubkeyHex) { |
||||
throw handleApiError(new Error('Authentication required'), { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo }, 'Authentication required'); |
||||
} |
||||
|
||||
const body = await event.request.json(); |
||||
const { name, description, cloneUrls, maintainers, chatRelays, isPrivate } = body; |
||||
|
||||
// Check if user is owner
|
||||
const currentOwner = await ownershipTransferService.getCurrentOwner(repoContext.repoOwnerPubkey, repoContext.repo); |
||||
if (requestContext.userPubkeyHex !== currentOwner) { |
||||
throw handleAuthorizationError('Only the repository owner can update settings', { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
// Get existing announcement
|
||||
const events = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||
authors: [currentOwner], |
||||
'#d': [repoContext.repo], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (events.length === 0) { |
||||
throw handleNotFoundError('Repository announcement not found', { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo }); |
||||
} |
||||
|
||||
const existingAnnouncement = events[0]; |
||||
|
||||
// Build updated tags
|
||||
const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; |
||||
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
||||
const protocol = isLocalhost ? 'http' : 'https'; |
||||
const gitUrl = `${protocol}://${gitDomain}/${repoContext.npub}/${repoContext.repo}.git`; |
||||
|
||||
// Get Tor .onion URL if available
|
||||
const { getTorGitUrl } = await import('$lib/services/tor/hidden-service.js'); |
||||
const torOnionUrl = await getTorGitUrl(repoContext.npub, repoContext.repo); |
||||
|
||||
// Filter user-provided clone URLs (exclude localhost and .onion duplicates)
|
||||
const userCloneUrls = (cloneUrls || []).filter((url: string) => { |
||||
if (!url || !url.trim()) return false; |
||||
// Exclude if it's our domain or already a .onion
|
||||
if (url.includes(gitDomain)) return false; |
||||
if (url.includes('.onion')) return false; |
||||
return true; |
||||
}); |
||||
|
||||
// Build clone URLs - NEVER include localhost, only include public domain or Tor .onion
|
||||
const cloneUrlList: string[] = []; |
||||
|
||||
// Add our domain URL only if it's NOT localhost (explicitly check the URL)
|
||||
if (!isLocalhost && !gitUrl.includes('localhost') && !gitUrl.includes('127.0.0.1')) { |
||||
cloneUrlList.push(gitUrl); |
||||
} |
||||
|
||||
// Add Tor .onion URL if available (always useful, even with localhost)
|
||||
if (torOnionUrl) { |
||||
cloneUrlList.push(torOnionUrl); |
||||
} |
||||
|
||||
// Add user-provided clone URLs
|
||||
cloneUrlList.push(...userCloneUrls); |
||||
|
||||
// Validate: If using localhost, require either Tor .onion URL or at least one other clone URL
|
||||
if (isLocalhost && !torOnionUrl && userCloneUrls.length === 0) { |
||||
throw error(400, 'Cannot update with only localhost. You need either a Tor .onion address or at least one other clone URL.'); |
||||
} |
||||
|
||||
const tags: string[][] = [ |
||||
['d', repoContext.repo], |
||||
['name', name || repoContext.repo], |
||||
...(description ? [['description', description]] : []), |
||||
['clone', ...cloneUrlList], |
||||
['relays', ...DEFAULT_NOSTR_RELAYS], |
||||
...(isPrivate ? [['private', 'true']] : []), |
||||
...(maintainers || []).map((m: string) => ['maintainers', m]), |
||||
...(chatRelays && chatRelays.length > 0 ? [['chat-relay', ...chatRelays]] : []) |
||||
]; |
||||
|
||||
// Preserve other tags from original announcement
|
||||
const preserveTags = ['r', 'web', 't']; |
||||
for (const tag of existingAnnouncement.tags) { |
||||
if (preserveTags.includes(tag[0]) && !tags.some(t => t[0] === tag[0])) { |
||||
tags.push(tag); |
||||
} |
||||
} |
||||
|
||||
// Create updated announcement
|
||||
const updatedAnnouncement = { |
||||
kind: KIND.REPO_ANNOUNCEMENT, |
||||
pubkey: currentOwner, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
content: '', |
||||
tags |
||||
}; |
||||
|
||||
// Sign and publish
|
||||
const signedEvent = await signEventWithNIP07(updatedAnnouncement); |
||||
|
||||
const { outbox } = await getUserRelays(currentOwner, nostrClient); |
||||
const combinedRelays = combineRelays(outbox); |
||||
|
||||
const result = await nostrClient.publishEvent(signedEvent, combinedRelays); |
||||
|
||||
if (result.success.length === 0) { |
||||
throw error(500, 'Failed to publish updated announcement to relays'); |
||||
} |
||||
|
||||
// Save updated announcement to repo (offline papertrail)
|
||||
try { |
||||
const announcementFileContent = generateVerificationFile(signedEvent, currentOwner); |
||||
|
||||
// Save to repo if it exists locally
|
||||
if (fileManager.repoExists(repoContext.npub, repoContext.repo)) { |
||||
await fileManager.writeFile( |
||||
repoContext.npub, |
||||
repoContext.repo, |
||||
VERIFICATION_FILE_PATH, |
||||
announcementFileContent, |
||||
`Update repository announcement: ${signedEvent.id.slice(0, 16)}...`, |
||||
'Nostr', |
||||
`${currentOwner}@nostr`, |
||||
'main' |
||||
).catch(err => { |
||||
// Log but don't fail - publishing to relays is more important
|
||||
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save updated announcement to repo'); |
||||
}); |
||||
} |
||||
} catch (err) { |
||||
// Log but don't fail - publishing to relays is more important
|
||||
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save updated announcement to repo'); |
||||
} |
||||
|
||||
return json({ success: true, event: signedEvent }); |
||||
}, |
||||
{ operation: 'updateSettings', requireRepoAccess: false } // Override to check owner instead
|
||||
); |
||||
@ -1,237 +0,0 @@
@@ -1,237 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { page } from '$app/stores'; |
||||
import { goto } from '$app/navigation'; |
||||
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||
import { userStore } from '$lib/stores/user-store.js'; |
||||
|
||||
const npub = ($page.params as { npub?: string; repo?: string }).npub || ''; |
||||
const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; |
||||
|
||||
let loading = $state(true); |
||||
let saving = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let userPubkey = $state<string | null>(null); |
||||
|
||||
// Sync with userStore |
||||
$effect(() => { |
||||
const currentUser = $userStore; |
||||
if (currentUser.userPubkey) { |
||||
userPubkey = currentUser.userPubkey; |
||||
} else { |
||||
userPubkey = null; |
||||
} |
||||
}); |
||||
|
||||
let name = $state(''); |
||||
let description = $state(''); |
||||
let cloneUrls = $state<string[]>(['']); |
||||
let maintainers = $state<string[]>(['']); |
||||
let chatRelays = $state<string[]>(['']); |
||||
let isPrivate = $state(false); |
||||
|
||||
onMount(async () => { |
||||
await checkAuth(); |
||||
await loadSettings(); |
||||
}); |
||||
|
||||
async function checkAuth() { |
||||
// Check userStore first |
||||
const currentUser = $userStore; |
||||
if (currentUser.userPubkey) { |
||||
userPubkey = currentUser.userPubkey; |
||||
return; |
||||
} |
||||
|
||||
// Fallback: try NIP-07 if store doesn't have it |
||||
try { |
||||
if (typeof window !== 'undefined' && window.nostr) { |
||||
userPubkey = await getPublicKeyWithNIP07(); |
||||
} |
||||
} catch (err) { |
||||
console.error('Auth check failed:', err); |
||||
} |
||||
} |
||||
|
||||
async function loadSettings() { |
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const response = await fetch(`/api/repos/${npub}/${repo}/settings?userPubkey=${userPubkey}`); |
||||
if (response.ok) { |
||||
const data = await response.json(); |
||||
name = data.name || ''; |
||||
description = data.description || ''; |
||||
cloneUrls = data.cloneUrls?.length > 0 ? data.cloneUrls : ['']; |
||||
maintainers = data.maintainers?.length > 0 ? data.maintainers : ['']; |
||||
chatRelays = data.chatRelays?.length > 0 ? data.chatRelays : ['']; |
||||
isPrivate = data.isPrivate || false; |
||||
} else { |
||||
const data = await response.json(); |
||||
error = data.error || 'Failed to load settings'; |
||||
if (response.status === 403) { |
||||
setTimeout(() => goto(`/repos/${npub}/${repo}`), 2000); |
||||
} |
||||
} |
||||
} catch (err) { |
||||
error = err instanceof Error ? err.message : 'Failed to load settings'; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
async function saveSettings() { |
||||
if (!userPubkey) { |
||||
error = 'Please connect your NIP-07 extension'; |
||||
return; |
||||
} |
||||
|
||||
saving = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const response = await fetch(`/api/repos/${npub}/${repo}/settings`, { |
||||
method: 'POST', |
||||
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify({ |
||||
userPubkey, |
||||
name, |
||||
description, |
||||
cloneUrls: cloneUrls.filter(url => url.trim()), |
||||
maintainers: maintainers.filter(m => m.trim()), |
||||
chatRelays: chatRelays.filter(url => url.trim()), |
||||
isPrivate |
||||
}) |
||||
}); |
||||
|
||||
if (response.ok) { |
||||
alert('Settings saved successfully!'); |
||||
goto(`/repos/${npub}/${repo}`); |
||||
} else { |
||||
const data = await response.json(); |
||||
error = data.error || 'Failed to save settings'; |
||||
} |
||||
} catch (err) { |
||||
error = err instanceof Error ? err.message : 'Failed to save settings'; |
||||
} finally { |
||||
saving = false; |
||||
} |
||||
} |
||||
|
||||
function addCloneUrl() { |
||||
cloneUrls = [...cloneUrls, '']; |
||||
} |
||||
|
||||
function removeCloneUrl(index: number) { |
||||
cloneUrls = cloneUrls.filter((_, i) => i !== index); |
||||
} |
||||
|
||||
function addMaintainer() { |
||||
maintainers = [...maintainers, '']; |
||||
} |
||||
|
||||
function removeMaintainer(index: number) { |
||||
maintainers = maintainers.filter((_, i) => i !== index); |
||||
} |
||||
|
||||
function addChatRelay() { |
||||
chatRelays = [...chatRelays, '']; |
||||
} |
||||
|
||||
function removeChatRelay(index: number) { |
||||
chatRelays = chatRelays.filter((_, i) => i !== index); |
||||
} |
||||
</script> |
||||
|
||||
<div class="container"> |
||||
<header> |
||||
<h1>Repository Settings</h1> |
||||
</header> |
||||
|
||||
<main> |
||||
{#if loading} |
||||
<div class="loading">Loading settings...</div> |
||||
{:else if error && !userPubkey} |
||||
<div class="error"> |
||||
{error} |
||||
<p>Redirecting to repository...</p> |
||||
</div> |
||||
{:else} |
||||
<form onsubmit={(e) => { e.preventDefault(); saveSettings(); }} class="settings-form"> |
||||
<div class="form-section"> |
||||
<h2>Basic Information</h2> |
||||
|
||||
<label> |
||||
Repository Name |
||||
<input type="text" bind:value={name} required /> |
||||
</label> |
||||
|
||||
<label> |
||||
Description |
||||
<textarea bind:value={description} rows="3"></textarea> |
||||
</label> |
||||
|
||||
<label> |
||||
<input type="checkbox" bind:checked={isPrivate} /> |
||||
Private Repository (only owners and maintainers can view) |
||||
</label> |
||||
</div> |
||||
|
||||
<div class="form-section"> |
||||
<h2>Clone URLs</h2> |
||||
<p class="help-text">Additional clone URLs (your server URL is automatically included)</p> |
||||
{#each cloneUrls as url, index} |
||||
<div class="array-input"> |
||||
<input type="url" bind:value={cloneUrls[index]} placeholder="https://example.com/repo.git" /> |
||||
{#if cloneUrls.length > 1} |
||||
<button type="button" onclick={() => removeCloneUrl(index)} class="remove-button">Remove</button> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
<button type="button" onclick={addCloneUrl} class="add-button">+ Add Clone URL</button> |
||||
</div> |
||||
|
||||
<div class="form-section"> |
||||
<h2>Maintainers</h2> |
||||
<p class="help-text">Additional maintainers (npub or hex pubkey)</p> |
||||
{#each maintainers as maintainer, index} |
||||
<div class="array-input"> |
||||
<input type="text" bind:value={maintainers[index]} placeholder="npub1..." /> |
||||
{#if maintainers.length > 1} |
||||
<button type="button" onclick={() => removeMaintainer(index)} class="remove-button">Remove</button> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
<button type="button" onclick={addMaintainer} class="add-button">+ Add Maintainer</button> |
||||
</div> |
||||
|
||||
<div class="form-section"> |
||||
<h2>Chat Relays</h2> |
||||
<p class="help-text">WebSocket relays for kind 11 discussion threads (e.g., wss://myprojechat.com, ws://localhost:2937)</p> |
||||
{#each chatRelays as relay, index} |
||||
<div class="array-input"> |
||||
<input type="text" bind:value={chatRelays[index]} placeholder="wss://example.com" /> |
||||
{#if chatRelays.length > 1} |
||||
<button type="button" onclick={() => removeChatRelay(index)} class="remove-button">Remove</button> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
<button type="button" onclick={addChatRelay} class="add-button">+ Add Chat Relay</button> |
||||
</div> |
||||
|
||||
{#if error} |
||||
<div class="error">{error}</div> |
||||
{/if} |
||||
|
||||
<div class="form-actions"> |
||||
<button type="button" onclick={() => goto(`/repos/${npub}/${repo}`)} class="cancel-button">Cancel</button> |
||||
<button type="submit" disabled={saving} class="save-button"> |
||||
{saving ? 'Saving...' : 'Save Settings'} |
||||
</button> |
||||
</div> |
||||
</form> |
||||
{/if} |
||||
</main> |
||||
</div> |
||||
|
||||
Loading…
Reference in new issue