clone of github.com/decent-newsroom/newsroom
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

285 lines
6.9 KiB

/**
* Nostr utilities for handling pubkeys, relays, and tag building
*/
// Bech32 character set
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
/**
* Decode bech32 string to hex
* Simplified implementation for npub decoding
*/
function bech32Decode(str: string): { prefix: string; data: Uint8Array } | null {
const lowered = str.toLowerCase();
// Find the separator
let sepIndex = lowered.lastIndexOf('1');
if (sepIndex < 1) return null;
const prefix = lowered.substring(0, sepIndex);
const dataStr = lowered.substring(sepIndex + 1);
if (dataStr.length < 6) return null;
// Decode the data
const values: number[] = [];
for (let i = 0; i < dataStr.length; i++) {
const c = dataStr[i];
const v = BECH32_CHARSET.indexOf(c);
if (v === -1) return null;
values.push(v);
}
// Remove checksum (last 6 chars)
const data = values.slice(0, -6);
// Convert from 5-bit to 8-bit
const bytes: number[] = [];
let acc = 0;
let bits = 0;
for (const value of data) {
acc = (acc << 5) | value;
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((acc >> bits) & 0xff);
}
}
return {
prefix,
data: new Uint8Array(bytes)
};
}
/**
* Convert Uint8Array to hex string
*/
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Convert npub to hex pubkey
*/
export function npubToHex(npub: string): string | null {
if (!npub.startsWith('npub1')) {
// Check if it's already hex
if (/^[0-9a-f]{64}$/i.test(npub)) {
return npub.toLowerCase();
}
return null;
}
try {
const decoded = bech32Decode(npub);
if (!decoded || decoded.prefix !== 'npub') {
return null;
}
return bytesToHex(decoded.data);
} catch (e) {
console.error('Error decoding npub:', e);
return null;
}
}
/**
* Validate if a string is a valid npub or hex pubkey
*/
export function isValidPubkey(pubkey: string): boolean {
if (!pubkey) return false;
// Check if hex (64 chars)
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
return true;
}
// Check if npub
if (pubkey.startsWith('npub1')) {
return npubToHex(pubkey) !== null;
}
return false;
}
/**
* Validate relay URL
*/
export function isValidRelay(relay: string): boolean {
if (!relay) return true; // Empty is valid (optional)
try {
const url = new URL(relay);
return url.protocol === 'wss:';
} catch {
return false;
}
}
/**
* ZapSplit interface
*/
export interface ZapSplit {
recipient: string;
relay?: string;
weight?: number;
sharePercent?: number;
}
/**
* Calculate share percentages for zap splits
*/
export function calculateShares(splits: ZapSplit[]): number[] {
if (splits.length === 0) {
return [];
}
// Check if any weights are specified
const hasWeights = splits.some(s => s.weight !== undefined && s.weight !== null && s.weight > 0);
if (!hasWeights) {
// Equal distribution
const equalShare = 100 / splits.length;
return splits.map(() => equalShare);
}
// Calculate total weight
const totalWeight = splits.reduce((sum, s) => sum + (s.weight || 0), 0);
if (totalWeight === 0) {
return splits.map(() => 0);
}
// Calculate weighted shares
return splits.map(s => {
const weight = s.weight || 0;
return (weight / totalWeight) * 100;
});
}
/**
* Build a zap tag for Nostr event
*/
export function buildZapTag(split: ZapSplit): (string | number)[] {
const hexPubkey = npubToHex(split.recipient);
if (!hexPubkey) {
throw new Error(`Invalid recipient pubkey: ${split.recipient}`);
}
const tag: (string | number)[] = ['zap', hexPubkey];
// Add relay (even if empty, to maintain position)
tag.push(split.relay || '');
// Add weight if specified
if (split.weight !== undefined && split.weight !== null) {
tag.push(split.weight);
}
return tag;
}
/**
* Advanced metadata interface
*/
export interface AdvancedMetadata {
doNotRepublish: boolean;
license: string;
customLicense?: string;
zapSplits: ZapSplit[];
contentWarning?: string;
expirationTimestamp?: number;
isProtected: boolean;
}
/**
* Build advanced metadata tags for Nostr event
*/
export function buildAdvancedTags(metadata: AdvancedMetadata): any[][] {
const tags: any[][] = [];
// Policy: Do not republish
if (metadata.doNotRepublish) {
tags.push(['L', 'rights.decent.newsroom']);
tags.push(['l', 'no-republish', 'rights.decent.newsroom']);
}
// License
const license = metadata.license === 'custom' ? metadata.customLicense : metadata.license;
if (license && license !== 'All rights reserved') {
tags.push(['L', 'spdx.org/licenses']);
tags.push(['l', license, 'spdx.org/licenses']);
} else if (license === 'All rights reserved') {
tags.push(['L', 'rights.decent.newsroom']);
tags.push(['l', 'all-rights-reserved', 'rights.decent.newsroom']);
}
// Zap splits
for (const split of metadata.zapSplits) {
try {
tags.push(buildZapTag(split));
} catch (e) {
console.error('Error building zap tag:', e);
}
}
// Content warning
if (metadata.contentWarning) {
tags.push(['content-warning', metadata.contentWarning]);
}
// Expiration
if (metadata.expirationTimestamp) {
tags.push(['expiration', metadata.expirationTimestamp.toString()]);
}
// Protected event
if (metadata.isProtected) {
tags.push(['-']);
}
return tags;
}
/**
* Validate advanced metadata before publishing
*/
export function validateAdvancedMetadata(metadata: AdvancedMetadata): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Validate zap splits
for (let i = 0; i < metadata.zapSplits.length; i++) {
const split = metadata.zapSplits[i];
if (!isValidPubkey(split.recipient)) {
errors.push(`Zap split ${i + 1}: Invalid recipient pubkey`);
}
if (split.relay && !isValidRelay(split.relay)) {
errors.push(`Zap split ${i + 1}: Invalid relay URL (must start with wss://)`);
}
if (split.weight !== undefined && split.weight !== null && split.weight < 0) {
errors.push(`Zap split ${i + 1}: Weight must be 0 or greater`);
}
}
// Validate expiration is in the future
if (metadata.expirationTimestamp) {
const now = Math.floor(Date.now() / 1000);
if (metadata.expirationTimestamp <= now) {
errors.push('Expiration date must be in the future');
}
}
return {
valid: errors.length === 0,
errors
};
}