242 lines
7.0 KiB
TypeScript
242 lines
7.0 KiB
TypeScript
import { nostrService } from './nostr'
|
|
import type { ArticleDraft, PublishedArticle } from './articlePublisherTypes'
|
|
import type { AlbyInvoice } from '@/types/alby'
|
|
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
|
import { encryptArticleContent, encryptDecryptionKey } from './articleEncryption'
|
|
import { storePrivateContent } from './articleStorage'
|
|
import type { PublishResult } from './publishResult'
|
|
import { writeOrchestrator } from './writeOrchestrator'
|
|
import { finalizeEvent } from 'nostr-tools'
|
|
import { hexToBytes } from 'nostr-tools/utils'
|
|
import { generatePublicationHashId } from './hashIdGenerator'
|
|
import { buildObjectId } from './urlGenerator'
|
|
import type { Article } from '@/types/nostr'
|
|
|
|
export function buildFailure(error?: string): PublishedArticle {
|
|
const base: PublishedArticle = {
|
|
articleId: '',
|
|
previewEventId: '',
|
|
success: false,
|
|
}
|
|
return error ? { ...base, error } : base
|
|
}
|
|
|
|
async function buildParsedArticleFromDraft(
|
|
draft: ArticleDraft,
|
|
invoice: AlbyInvoice,
|
|
authorPubkey: string
|
|
): Promise<{ article: Article; hash: string; version: number; index: number }> {
|
|
let category: string
|
|
if (draft.category === 'science-fiction') {
|
|
category = 'sciencefiction'
|
|
} else if (draft.category === 'scientific-research') {
|
|
category = 'research'
|
|
} else {
|
|
category = 'sciencefiction'
|
|
}
|
|
|
|
const hashId = await generatePublicationHashId({
|
|
pubkey: authorPubkey,
|
|
title: draft.title,
|
|
preview: draft.preview,
|
|
category,
|
|
seriesId: draft.seriesId ?? undefined,
|
|
bannerUrl: draft.bannerUrl ?? undefined,
|
|
zapAmount: draft.zapAmount,
|
|
})
|
|
|
|
const hash = hashId
|
|
const version = 0
|
|
const index = 0
|
|
const id = buildObjectId(hash, index, version)
|
|
|
|
const article: Article = {
|
|
id,
|
|
hash,
|
|
version,
|
|
index,
|
|
pubkey: authorPubkey,
|
|
title: draft.title,
|
|
preview: draft.preview,
|
|
content: '',
|
|
description: draft.preview,
|
|
contentDescription: draft.preview,
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
zapAmount: draft.zapAmount,
|
|
paid: false,
|
|
thumbnailUrl: draft.bannerUrl ?? '',
|
|
invoice: invoice.invoice,
|
|
...(invoice.paymentHash ? { paymentHash: invoice.paymentHash } : {}),
|
|
...(draft.category ? { category: draft.category } : {}),
|
|
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
|
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
|
...(draft.pages && draft.pages.length > 0 ? { pages: draft.pages } : {}),
|
|
kindType: 'article',
|
|
}
|
|
|
|
return { article, hash, version, index }
|
|
}
|
|
|
|
interface PublishPreviewParams {
|
|
draft: ArticleDraft
|
|
invoice: AlbyInvoice
|
|
authorPubkey: string
|
|
presentationId: string
|
|
extraTags?: string[][]
|
|
encryptedContent?: string
|
|
encryptedKey?: string
|
|
returnStatus?: boolean
|
|
}
|
|
|
|
export async function publishPreview(
|
|
params: PublishPreviewParams
|
|
): Promise<import('nostr-tools').Event | null | PublishResult> {
|
|
const { draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey, returnStatus } = params
|
|
// Build parsed article object
|
|
const { article, hash, version, index } = await buildParsedArticleFromDraft(draft, invoice, authorPubkey)
|
|
|
|
// Build event template
|
|
const previewEventTemplate = await createPreviewEvent({
|
|
draft,
|
|
invoice,
|
|
authorPubkey,
|
|
authorPresentationId: presentationId,
|
|
extraTags,
|
|
encryptedContent,
|
|
encryptedKey,
|
|
})
|
|
|
|
// Set private key in orchestrator
|
|
const privateKey = nostrService.getPrivateKey()
|
|
if (!privateKey) {
|
|
throw new Error('Private key required for signing')
|
|
}
|
|
writeOrchestrator.setPrivateKey(privateKey)
|
|
|
|
// Finalize event
|
|
const secretKey = hexToBytes(privateKey)
|
|
const event = finalizeEvent(previewEventTemplate, secretKey)
|
|
|
|
// Get active relays
|
|
const { relaySessionManager } = await import('./relaySessionManager')
|
|
const activeRelays = await relaySessionManager.getActiveRelays()
|
|
const { getPrimaryRelay } = await import('./config')
|
|
const relays = activeRelays.length > 0 ? activeRelays : [await getPrimaryRelay()]
|
|
|
|
// Publish via writeOrchestrator (parallel network + local write)
|
|
const result = await writeOrchestrator.writeAndPublish(
|
|
{
|
|
objectType: 'publication',
|
|
hash,
|
|
event,
|
|
parsed: article,
|
|
version,
|
|
hidden: false,
|
|
index,
|
|
},
|
|
relays
|
|
)
|
|
|
|
if (!result.success) {
|
|
return null
|
|
}
|
|
|
|
if (returnStatus) {
|
|
// Return PublishResult format
|
|
return {
|
|
event,
|
|
relayStatuses: relays.map((relayUrl, _idx) => {
|
|
const isSuccess = typeof result.published === 'object' && result.published.includes(relayUrl)
|
|
return {
|
|
relayUrl,
|
|
success: isSuccess,
|
|
error: isSuccess ? undefined : 'Failed to publish',
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
export function buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable<ArticleDraft['category']>): string[][] {
|
|
// Media tags are still supported in the new system
|
|
const extraTags: string[][] = []
|
|
if (draft.media && draft.media.length > 0) {
|
|
draft.media.forEach((m) => {
|
|
extraTags.push(['media', m.url, m.type])
|
|
})
|
|
}
|
|
return extraTags
|
|
}
|
|
|
|
export async function encryptAndPublish(
|
|
params: {
|
|
draft: ArticleDraft
|
|
authorPubkey: string
|
|
authorPrivateKeyForEncryption: string
|
|
category: NonNullable<ArticleDraft['category']>
|
|
presentationId: string
|
|
}
|
|
): Promise<PublishedArticle> {
|
|
const { draft, authorPubkey, authorPrivateKeyForEncryption, category, presentationId } = params
|
|
const { encryptedContent, key, iv } = await encryptArticleContent(draft.content)
|
|
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
|
|
const invoice = await createArticleInvoice(draft)
|
|
const extraTags = buildArticleExtraTags(draft, category)
|
|
const publishResult = await publishPreview({
|
|
draft,
|
|
invoice,
|
|
authorPubkey,
|
|
presentationId,
|
|
extraTags,
|
|
encryptedContent,
|
|
encryptedKey,
|
|
returnStatus: true,
|
|
})
|
|
|
|
if (!publishResult) {
|
|
return buildFailure('Failed to publish article')
|
|
}
|
|
|
|
// Handle both old format (Event | null) and new format (PublishResult)
|
|
let event: import('nostr-tools').Event | null = null
|
|
let relayStatuses: import('./publishResult').RelayPublishStatus[] | undefined
|
|
|
|
if (publishResult && 'event' in publishResult && 'relayStatuses' in publishResult) {
|
|
// New format with statuses
|
|
event = publishResult.event
|
|
relayStatuses = publishResult.relayStatuses
|
|
} else if (publishResult && 'id' in publishResult) {
|
|
// Old format (Event)
|
|
event = publishResult
|
|
}
|
|
|
|
if (!event) {
|
|
return buildFailure('Failed to publish article')
|
|
}
|
|
|
|
await storePrivateContent({
|
|
articleId: event.id,
|
|
content: draft.content,
|
|
authorPubkey,
|
|
invoice,
|
|
decryptionKey: key,
|
|
decryptionIV: iv,
|
|
})
|
|
console.warn('Article published with encrypted content', {
|
|
articleId: event.id,
|
|
authorPubkey,
|
|
timestamp: new Date().toISOString(),
|
|
relayStatuses,
|
|
})
|
|
|
|
return {
|
|
articleId: event.id,
|
|
previewEventId: event.id,
|
|
invoice,
|
|
success: true,
|
|
relayStatuses,
|
|
}
|
|
}
|