first commit
Made-with: Cursor
This commit is contained in:
90
backend/src/services/auth.ts
Normal file
90
backend/src/services/auth.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface StoredChallenge {
|
||||
challenge: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const challenges = new Map<string, StoredChallenge>();
|
||||
|
||||
// Periodically clean up expired challenges
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of challenges) {
|
||||
if (value.expiresAt < now) {
|
||||
challenges.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
export const authService = {
|
||||
createChallenge(pubkey: string): string {
|
||||
const challenge = uuidv4();
|
||||
challenges.set(pubkey, {
|
||||
challenge,
|
||||
expiresAt: Date.now() + CHALLENGE_TTL_MS,
|
||||
});
|
||||
return challenge;
|
||||
},
|
||||
|
||||
verifySignature(pubkey: string, signedEvent: VerifiedEvent): boolean {
|
||||
const stored = challenges.get(pubkey);
|
||||
if (!stored) return false;
|
||||
if (stored.expiresAt < Date.now()) {
|
||||
challenges.delete(pubkey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the event signature
|
||||
if (!verifyEvent(signedEvent)) return false;
|
||||
|
||||
// Kind 22242 is the NIP-42 auth kind
|
||||
if (signedEvent.kind !== 22242) return false;
|
||||
if (signedEvent.pubkey !== pubkey) return false;
|
||||
|
||||
// Check that the challenge tag matches
|
||||
const challengeTag = signedEvent.tags.find(
|
||||
(t) => t[0] === 'challenge'
|
||||
);
|
||||
if (!challengeTag || challengeTag[1] !== stored.challenge) return false;
|
||||
|
||||
challenges.delete(pubkey);
|
||||
return true;
|
||||
},
|
||||
|
||||
generateToken(pubkey: string, role: string): string {
|
||||
return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' });
|
||||
},
|
||||
|
||||
async getRole(pubkey: string): Promise<string> {
|
||||
const adminPubkeys = (process.env.ADMIN_PUBKEYS || '')
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((p) => {
|
||||
if (p.startsWith('npub1')) {
|
||||
try {
|
||||
const { data } = nip19.decode(p);
|
||||
return data as string;
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
if (adminPubkeys.includes(pubkey)) return 'ADMIN';
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { pubkey } });
|
||||
if (user?.role === 'MODERATOR') return 'MODERATOR';
|
||||
if (user?.role === 'ADMIN') return 'ADMIN';
|
||||
|
||||
return 'USER';
|
||||
},
|
||||
};
|
||||
146
backend/src/services/nostr.ts
Normal file
146
backend/src/services/nostr.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { SimplePool, nip19 } from 'nostr-tools';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
const pool = new SimplePool();
|
||||
|
||||
async function getRelayUrls(): Promise<string[]> {
|
||||
const relays = await prisma.relay.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
return relays.map((r) => r.url);
|
||||
}
|
||||
|
||||
export const nostrService = {
|
||||
async fetchEvent(eventId: string, skipCache = false) {
|
||||
if (!skipCache) {
|
||||
const cached = await prisma.nostrEventCache.findUnique({
|
||||
where: { eventId },
|
||||
});
|
||||
if (cached) {
|
||||
return {
|
||||
id: cached.eventId,
|
||||
kind: cached.kind,
|
||||
pubkey: cached.pubkey,
|
||||
content: cached.content,
|
||||
tags: JSON.parse(cached.tags),
|
||||
created_at: cached.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const relays = await getRelayUrls();
|
||||
if (relays.length === 0) return null;
|
||||
|
||||
try {
|
||||
const event = await pool.get(relays, { ids: [eventId] });
|
||||
if (!event) return null;
|
||||
|
||||
await prisma.nostrEventCache.upsert({
|
||||
where: { eventId: event.id },
|
||||
update: {
|
||||
content: event.content,
|
||||
tags: JSON.stringify(event.tags),
|
||||
},
|
||||
create: {
|
||||
eventId: event.id,
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
content: event.content,
|
||||
tags: JSON.stringify(event.tags),
|
||||
createdAt: event.created_at,
|
||||
},
|
||||
});
|
||||
|
||||
return event;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch event:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchLongformEvent(naddrStr: string) {
|
||||
let decoded: nip19.AddressPointer;
|
||||
try {
|
||||
const result = nip19.decode(naddrStr);
|
||||
if (result.type !== 'naddr') return null;
|
||||
decoded = result.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relays = decoded.relays?.length
|
||||
? decoded.relays
|
||||
: await getRelayUrls();
|
||||
if (relays.length === 0) return null;
|
||||
|
||||
try {
|
||||
const event = await pool.get(relays, {
|
||||
kinds: [decoded.kind],
|
||||
authors: [decoded.pubkey],
|
||||
'#d': [decoded.identifier],
|
||||
});
|
||||
if (!event) return null;
|
||||
|
||||
await prisma.nostrEventCache.upsert({
|
||||
where: { eventId: event.id },
|
||||
update: {
|
||||
content: event.content,
|
||||
tags: JSON.stringify(event.tags),
|
||||
},
|
||||
create: {
|
||||
eventId: event.id,
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
content: event.content,
|
||||
tags: JSON.stringify(event.tags),
|
||||
createdAt: event.created_at,
|
||||
},
|
||||
});
|
||||
|
||||
return event;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch longform event:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchReactions(eventId: string) {
|
||||
const relays = await getRelayUrls();
|
||||
if (relays.length === 0) return [];
|
||||
|
||||
try {
|
||||
const events = await pool.querySync(relays, {
|
||||
kinds: [7],
|
||||
'#e': [eventId],
|
||||
});
|
||||
return events;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch reactions:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async fetchReplies(eventId: string) {
|
||||
const relays = await getRelayUrls();
|
||||
if (relays.length === 0) return [];
|
||||
|
||||
try {
|
||||
const events = await pool.querySync(relays, {
|
||||
kinds: [1],
|
||||
'#e': [eventId],
|
||||
});
|
||||
return events;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch replies:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async getRelays() {
|
||||
return prisma.relay.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { priority: 'asc' },
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user