Browse Source
Nostr-Signature: 5d6d6909666a881f88f240389d30f5bedd36dba5d69a9d24dca86557b0098867 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc d13caca8b3e1009469e28c352bdfacf5eb78e2e9f5ac80c8511a9e2c6c5ac7031b83374d2d91b93b8018b5a3402e3e9c7114332da89ee2cb039f64aa3207f3f4main
11 changed files with 728 additions and 15 deletions
@ -0,0 +1,233 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { createEventDispatcher } from 'svelte'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
|
||||||
|
const dispatch = createEventDispatcher(); |
||||||
|
|
||||||
|
type TransferData = { |
||||||
|
eventId: string; |
||||||
|
fromPubkey: string; |
||||||
|
toPubkey: string; |
||||||
|
repoTag: string; |
||||||
|
repoName: string; |
||||||
|
originalOwner: string; |
||||||
|
timestamp: number; |
||||||
|
createdAt: string; |
||||||
|
event: NostrEvent; |
||||||
|
}; |
||||||
|
|
||||||
|
let { transfer }: { transfer: TransferData } = $props(); |
||||||
|
|
||||||
|
let closing = $state(false); |
||||||
|
|
||||||
|
function handleCompleteTransfer() { |
||||||
|
// Parse repo info from repoTag (kind:pubkey:repo) |
||||||
|
const currentTransfer = transfer; |
||||||
|
const parts = currentTransfer.repoTag.split(':'); |
||||||
|
if (parts.length < 3) { |
||||||
|
alert('Invalid repository tag format'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const originalOwnerPubkey = parts[1]; |
||||||
|
const repoName = parts[2]; |
||||||
|
|
||||||
|
// Convert original owner pubkey to npub |
||||||
|
let originalOwnerNpub: string; |
||||||
|
try { |
||||||
|
originalOwnerNpub = nip19.npubEncode(originalOwnerPubkey); |
||||||
|
} catch { |
||||||
|
alert('Invalid owner pubkey format'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Navigate to signup page with transfer data |
||||||
|
const params = new URLSearchParams({ |
||||||
|
transfer: 'true', |
||||||
|
transferEventId: currentTransfer.eventId, |
||||||
|
originalOwner: originalOwnerNpub, |
||||||
|
repo: repoName, |
||||||
|
repoTag: currentTransfer.repoTag |
||||||
|
}); |
||||||
|
|
||||||
|
goto(`/signup?${params.toString()}`); |
||||||
|
} |
||||||
|
|
||||||
|
function handleDismiss() { |
||||||
|
closing = true; |
||||||
|
const currentTransfer = transfer; |
||||||
|
dispatch('dismiss', { eventId: currentTransfer.eventId }); |
||||||
|
setTimeout(() => { |
||||||
|
// Component will be removed by parent |
||||||
|
}, 300); |
||||||
|
} |
||||||
|
|
||||||
|
// Format timestamp |
||||||
|
const formattedDate = $derived(new Date(transfer.createdAt).toLocaleDateString()); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="transfer-notification" class:closing> |
||||||
|
<div class="notification-content"> |
||||||
|
<div class="notification-header"> |
||||||
|
<h3>Repository Ownership Transfer</h3> |
||||||
|
<button class="close-button" onclick={handleDismiss} aria-label="Dismiss"> |
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line> |
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<div class="notification-body"> |
||||||
|
<p> |
||||||
|
You have been named as the new owner of the repository: <strong>{transfer.repoName}</strong> |
||||||
|
</p> |
||||||
|
<p class="notification-details"> |
||||||
|
Transfer initiated on {formattedDate} |
||||||
|
</p> |
||||||
|
<p class="notification-instruction"> |
||||||
|
Please complete the transfer by publishing a new repo announcement. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<div class="notification-actions"> |
||||||
|
<button class="button-primary" onclick={handleCompleteTransfer}> |
||||||
|
Complete Transfer |
||||||
|
</button> |
||||||
|
<button class="button-secondary" onclick={handleDismiss}> |
||||||
|
Dismiss |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.transfer-notification { |
||||||
|
position: fixed; |
||||||
|
top: 20px; |
||||||
|
right: 20px; |
||||||
|
z-index: 1000; |
||||||
|
max-width: 500px; |
||||||
|
background: var(--bg-primary, #fff); |
||||||
|
border: 2px solid var(--border-color, #ddd); |
||||||
|
border-radius: 8px; |
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
||||||
|
animation: slideIn 0.3s ease-out; |
||||||
|
transition: opacity 0.3s ease-out, transform 0.3s ease-out; |
||||||
|
} |
||||||
|
|
||||||
|
.transfer-notification.closing { |
||||||
|
opacity: 0; |
||||||
|
transform: translateX(100%); |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes slideIn { |
||||||
|
from { |
||||||
|
opacity: 0; |
||||||
|
transform: translateX(100%); |
||||||
|
} |
||||||
|
to { |
||||||
|
opacity: 1; |
||||||
|
transform: translateX(0); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.notification-content { |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.notification-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 16px; |
||||||
|
} |
||||||
|
|
||||||
|
.notification-header h3 { |
||||||
|
margin: 0; |
||||||
|
font-size: 1.2em; |
||||||
|
color: var(--text-primary, #333); |
||||||
|
} |
||||||
|
|
||||||
|
.close-button { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
cursor: pointer; |
||||||
|
padding: 4px; |
||||||
|
color: var(--text-secondary, #666); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
border-radius: 4px; |
||||||
|
transition: background-color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.close-button:hover { |
||||||
|
background-color: var(--bg-secondary, #f0f0f0); |
||||||
|
} |
||||||
|
|
||||||
|
.notification-body { |
||||||
|
margin-bottom: 16px; |
||||||
|
} |
||||||
|
|
||||||
|
.notification-body p { |
||||||
|
margin: 8px 0; |
||||||
|
color: var(--text-primary, #333); |
||||||
|
line-height: 1.5; |
||||||
|
} |
||||||
|
|
||||||
|
.notification-details { |
||||||
|
font-size: 0.9em; |
||||||
|
color: var(--text-secondary, #666); |
||||||
|
} |
||||||
|
|
||||||
|
.notification-instruction { |
||||||
|
font-weight: 500; |
||||||
|
color: var(--text-primary, #333); |
||||||
|
} |
||||||
|
|
||||||
|
.notification-actions { |
||||||
|
display: flex; |
||||||
|
gap: 12px; |
||||||
|
justify-content: flex-end; |
||||||
|
} |
||||||
|
|
||||||
|
.button-primary, |
||||||
|
.button-secondary { |
||||||
|
padding: 10px 20px; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 1em; |
||||||
|
font-weight: 500; |
||||||
|
transition: background-color 0.2s, transform 0.1s; |
||||||
|
} |
||||||
|
|
||||||
|
.button-primary { |
||||||
|
background-color: var(--primary-color, #007bff); |
||||||
|
color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.button-primary:hover { |
||||||
|
background-color: var(--primary-hover, #0056b3); |
||||||
|
transform: translateY(-1px); |
||||||
|
} |
||||||
|
|
||||||
|
.button-secondary { |
||||||
|
background-color: var(--bg-secondary, #f0f0f0); |
||||||
|
color: var(--text-primary, #333); |
||||||
|
} |
||||||
|
|
||||||
|
.button-secondary:hover { |
||||||
|
background-color: var(--bg-tertiary, #e0e0e0); |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 600px) { |
||||||
|
.transfer-notification { |
||||||
|
top: 10px; |
||||||
|
right: 10px; |
||||||
|
left: 10px; |
||||||
|
max-width: none; |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,126 @@ |
|||||||
|
/** |
||||||
|
* API endpoint to check for pending ownership transfers for the logged-in user |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { nostrClient } from '$lib/services/service-registry.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||||
|
import logger from '$lib/services/logger.js'; |
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ request }) => { |
||||||
|
const userPubkeyHex = request.headers.get('X-User-Pubkey'); |
||||||
|
|
||||||
|
if (!userPubkeyHex) { |
||||||
|
return json({ pendingTransfers: [] }); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Get user's relays for comprehensive search
|
||||||
|
const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient); |
||||||
|
// Combine user relays with default and search relays
|
||||||
|
const userRelays = [...inbox, ...outbox]; |
||||||
|
const allRelays = [...new Set([...userRelays, ...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]; |
||||||
|
|
||||||
|
// Create a new client with all relays for comprehensive search
|
||||||
|
const { NostrClient } = await import('$lib/services/nostr/nostr-client.js'); |
||||||
|
const searchClient = new NostrClient(allRelays); |
||||||
|
|
||||||
|
// Search for transfer events where this user is the new owner (p tag)
|
||||||
|
const transferEvents = await searchClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.OWNERSHIP_TRANSFER], |
||||||
|
'#p': [userPubkeyHex], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// Filter for valid, non-self-transfer events that haven't been completed
|
||||||
|
const pendingTransfers: Array<{ |
||||||
|
eventId: string; |
||||||
|
fromPubkey: string; |
||||||
|
toPubkey: string; |
||||||
|
repoTag: string; |
||||||
|
repoName: string; |
||||||
|
originalOwner: string; |
||||||
|
timestamp: number; |
||||||
|
createdAt: string; |
||||||
|
event: NostrEvent; |
||||||
|
}> = []; |
||||||
|
|
||||||
|
for (const event of transferEvents) { |
||||||
|
// Verify event signature
|
||||||
|
if (!verifyEvent(event)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Skip self-transfers
|
||||||
|
if (event.pubkey === userPubkeyHex) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract repo tag
|
||||||
|
const aTag = event.tags.find(t => t[0] === 'a'); |
||||||
|
if (!aTag || !aTag[1]) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Parse repo tag: kind:pubkey:repo
|
||||||
|
const repoTag = aTag[1]; |
||||||
|
const parts = repoTag.split(':'); |
||||||
|
if (parts.length < 3) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
const originalOwner = parts[1]; |
||||||
|
const repoName = parts[2]; |
||||||
|
|
||||||
|
// Extract new owner (p tag)
|
||||||
|
const pTag = event.tags.find(t => t[0] === 'p'); |
||||||
|
if (!pTag || !pTag[1] || pTag[1] !== userPubkeyHex) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if transfer is already completed by checking for a newer repo announcement from the new owner
|
||||||
|
// This is a simple check - if there's a newer announcement from the new owner for this repo, transfer is complete
|
||||||
|
const newerAnnouncements = await searchClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [userPubkeyHex], |
||||||
|
'#d': [repoName], |
||||||
|
since: event.created_at, |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// If there's a newer announcement from the new owner, transfer is complete
|
||||||
|
if (newerAnnouncements.length > 0) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
pendingTransfers.push({ |
||||||
|
eventId: event.id, |
||||||
|
fromPubkey: event.pubkey, |
||||||
|
toPubkey: userPubkeyHex, |
||||||
|
repoTag, |
||||||
|
repoName, |
||||||
|
originalOwner, |
||||||
|
timestamp: event.created_at, |
||||||
|
createdAt: new Date(event.created_at * 1000).toISOString(), |
||||||
|
event |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by timestamp (newest first)
|
||||||
|
pendingTransfers.sort((a, b) => b.timestamp - a.timestamp); |
||||||
|
|
||||||
|
return json({ pendingTransfers }); |
||||||
|
} catch (err) { |
||||||
|
logger.error({ error: err, userPubkeyHex }, 'Error checking for pending transfers'); |
||||||
|
return json({ pendingTransfers: [], error: 'Failed to check for pending transfers' }); |
||||||
|
} |
||||||
|
}; |
||||||
Loading…
Reference in new issue