From 758ab5c96622f2fd20ee2af54c8d082eb77ddfda Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Tue, 6 Jan 2026 00:26:31 +0100 Subject: [PATCH] series building --- components/ArticlesList.tsx | 2 +- components/AuthorsList.tsx | 2 +- components/HomeView.tsx | 2 +- components/ImageUploadField.tsx | 30 +- components/PageHeader.tsx | 24 +- components/ProfileHeader.tsx | 24 +- docs/tag-system-explanation.md | 169 +++++++ hooks/useArticles.ts | 3 +- lib/accessControl.ts | 106 +++++ lib/articleInvoice.ts | 32 +- lib/articleMutations.ts | 73 ++- lib/articlePublisher.ts | 9 +- lib/articlePublisherHelpersPresentation.ts | 164 ++++++- lib/articlePublisherPublish.ts | 5 +- lib/articleQueries.ts | 2 + lib/duplicateDetector.ts | 124 ++++++ lib/hashIdGenerator.ts | 199 +++++++++ lib/keyManagement.ts | 2 +- lib/metadataExtractor.ts | 491 +++++++++++++++++++++ lib/nostr.ts | 23 +- lib/nostrTagSystemBuild.ts | 7 + lib/nostrTagSystemExtract.ts | 6 + lib/nostrTagSystemFilter.ts | 2 + lib/nostrTagSystemTypes.ts | 4 + lib/objectModification.ts | 97 ++++ lib/platformConfig.ts | 28 +- lib/presentationParsing.ts | 34 +- lib/reviews.ts | 2 + lib/seriesQueries.ts | 3 + lib/sponsoring.ts | 2 + lib/urlGenerator.ts | 56 +++ lib/versionManager.ts | 139 ++++++ locales/en.txt | 5 + locales/fr.txt | 5 + pages/api/nip95-upload.ts | 14 +- public/locales/en.txt | 5 + public/locales/fr.txt | 5 + 37 files changed, 1796 insertions(+), 104 deletions(-) create mode 100644 docs/tag-system-explanation.md create mode 100644 lib/accessControl.ts create mode 100644 lib/duplicateDetector.ts create mode 100644 lib/hashIdGenerator.ts create mode 100644 lib/metadataExtractor.ts create mode 100644 lib/objectModification.ts create mode 100644 lib/urlGenerator.ts create mode 100644 lib/versionManager.ts diff --git a/components/ArticlesList.tsx b/components/ArticlesList.tsx index 5b6bbb3..e393d99 100644 --- a/components/ArticlesList.tsx +++ b/components/ArticlesList.tsx @@ -31,7 +31,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }) { return (

- {hasAny ? 'No articles match your search or filters.' : 'No articles found. Check back later!'} + {hasAny ? t('common.empty.articles.filtered') : t('common.empty.articles')}

) diff --git a/components/AuthorsList.tsx b/components/AuthorsList.tsx index bdeb91f..8169599 100644 --- a/components/AuthorsList.tsx +++ b/components/AuthorsList.tsx @@ -29,7 +29,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }) { return (

- {hasAny ? 'No authors match your search or filters.' : 'No authors found. Check back later!'} + {hasAny ? t('common.empty.authors.filtered') : t('common.empty.authors')}

) diff --git a/components/HomeView.tsx b/components/HomeView.tsx index 01c8ac8..d6aacff 100644 --- a/components/HomeView.tsx +++ b/components/HomeView.tsx @@ -116,7 +116,7 @@ function HomeContent({ {shouldShowAuthors ? ( ) : ( - + )} diff --git a/components/ImageUploadField.tsx b/components/ImageUploadField.tsx index ccf91e3..1775bf6 100644 --- a/components/ImageUploadField.tsx +++ b/components/ImageUploadField.tsx @@ -151,21 +151,21 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image return ( <> -
- - {value && } - - {error &&

{error}

} - {displayHelpText &&

{displayHelpText}

} -
+
+ + {value && } + + {error &&

{error}

} + {displayHelpText &&

{displayHelpText}

} +
{showUnlockModal && ( + + + ) +} + export function PageHeader() { return (
- + {t('home.title')} + + +
diff --git a/components/ProfileHeader.tsx b/components/ProfileHeader.tsx index 664ceac..ef88ef6 100644 --- a/components/ProfileHeader.tsx +++ b/components/ProfileHeader.tsx @@ -2,12 +2,34 @@ import { ConnectButton } from '@/components/ConnectButton' import { ConditionalPublishButton } from './ConditionalPublishButton' import { KeyIndicator } from './KeyIndicator' +function GitIcon() { + return ( + + + + ) +} + export function ProfileHeader() { return (
-

+

zapwall.fr + + +

diff --git a/docs/tag-system-explanation.md b/docs/tag-system-explanation.md new file mode 100644 index 0000000..73e7f34 --- /dev/null +++ b/docs/tag-system-explanation.md @@ -0,0 +1,169 @@ +# Système de tags zapwall.fr + +## Vue d'ensemble + +Le système de tags de zapwall.fr utilise un système de tags personnalisé basé sur les tags standards de Nostr (kind 1 notes). Tous les événements sont des notes Nostr (kind 1), et le système utilise des tags en anglais pour identifier le type de contenu, la catégorie, et les métadonnées associées. + +## Structure des tags + +### Tags de base (tous les types) + +Tous les événements incluent ces tags de base : + +- **Type** : Tag simple (sans valeur) qui identifie le type de contenu + - `#author` : Présentation d'auteur + - `#series` : Série d'articles + - `#publication` : Article/publication + - `#quote` : Avis/review + +- **Catégorie** : Tag simple qui identifie la catégorie + - `#sciencefiction` : Science-fiction + - `#research` : Recherche scientifique + +- **Identifiant** : Tag avec valeur pour l'ID unique + - `["id", ""]` : Identifiant unique de l'événement + +- **Service** : Tag avec valeur pour identifier la plateforme + - `["service", "zapwall.fr"]` : Identifiant du service (toujours présent pour filtrer toutes les notes de zapwall.fr) + +- **Paywall** : Tag simple (optionnel) + - `#paywall` : Indique que le contenu est payant + +- **Payment** : Tag simple (optionnel) + - `#payment` : Indique qu'un paiement a été effectué + +### Tags spécifiques par type + +#### Tags pour `#author` (présentation d'auteur) + +```typescript +["author"] // Type +["sciencefiction"] ou ["research"] // Catégorie +["id", ""] // ID unique +["title", ""] // Titre de la présentation +["preview", ""] // Aperçu (optionnel) +["mainnet_address", ""] // Adresse Bitcoin mainnet pour le sponsoring +["total_sponsoring", ""] // Total du sponsoring reçu (en sats) +["picture", ""] // URL de la photo de profil (optionnel) +``` + +**Exemple de tags pour une présentation d'auteur :** +``` +[ + ["author"], + ["sciencefiction"], + ["id", "abc123..."], + ["service", "zapwall.fr"], + ["title", "Présentation de John Doe"], + ["preview", "Aperçu de la présentation..."], + ["mainnet_address", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"], + ["total_sponsoring", "0"], + ["picture", "https://cdn.nostrcheck.me/..."] +] +``` + +#### Tags pour `#publication` (article) + +```typescript +["publication"] // Type +["sciencefiction"] ou ["research"] // Catégorie +["id", ""] // ID unique +["title", ""] // Titre de l'article +["preview", ""] // Aperçu (optionnel) +["series", ""] // ID de la série (optionnel) +["banner", ""] // URL de la bannière (optionnel) +["zap", ""] // Montant en sats pour débloquer +["invoice", ""] // Facture BOLT11 (optionnel) +["payment_hash", ""] // Hash du paiement (optionnel) +["encrypted_key", ""] // Clé de chiffrement (optionnel) +``` + +#### Tags pour `#series` (série) + +```typescript +["series"] // Type +["sciencefiction"] ou ["research"] // Catégorie +["id", ""] // ID unique +["title", ""] // Titre de la série +["description", ""] // Description de la série +["preview", ""] // Aperçu (optionnel) +["cover", ""] // URL de la couverture (optionnel) +``` + +#### Tags pour `#quote` (avis/review) + +```typescript +["quote"] // Type +["article", ""] // ID de l'article commenté +["reviewer", ""] // Clé publique du reviewer (optionnel) +["title", ""] // Titre de l'avis (optionnel) +``` + +## Filtrage et requêtes + +Le système utilise `buildTagFilter` pour construire des filtres de requête Nostr : + +```typescript +// Exemple : récupérer toutes les présentations d'auteurs de zapwall.fr +buildTagFilter({ + type: 'author', + category: 'sciencefiction', + service: 'zapwall.fr' +}) +// Résultat : { kinds: [1], "#author": [""], "#sciencefiction": [""], "#service": ["zapwall.fr"] } + +// Exemple : récupérer une présentation spécifique +buildTagFilter({ + type: 'author', + authorPubkey: 'abc123...', + service: 'zapwall.fr' +}) +// Résultat : { kinds: [1], "#author": [""], "#service": ["zapwall.fr"], authors: ["abc123..."] } + +// Exemple : récupérer toutes les notes de zapwall.fr +buildTagFilter({ + service: 'zapwall.fr' +}) +// Résultat : { kinds: [1], "#service": ["zapwall.fr"] } +``` + +## Extraction des tags + +Le système utilise `extractTagsFromEvent` pour extraire les tags d'un événement : + +```typescript +const tags = extractTagsFromEvent(event) +// Retourne un objet avec : +// - type: 'author' | 'series' | 'publication' | 'quote' +// - category: 'sciencefiction' | 'research' +// - id: string +// - paywall: boolean +// - payment: boolean +// - title, preview, mainnetAddress, etc. selon le type +``` + +## Tag service + +Le tag `["service", "zapwall.fr"]` est utilisé pour identifier toutes les notes publiées par la plateforme zapwall.fr. Ce tag permet de : + +- **Filtrer toutes les notes de la plateforme** : `buildTagFilter({ service: 'zapwall.fr' })` +- **Distinguer les notes zapwall.fr** des autres notes Nostr sur le réseau +- **Améliorer les performances** en filtrant dès la source lors des requêtes + +**Note** : Aucun NIP (Nostr Improvement Proposal) ne spécifie actuellement un tag standardisé pour identifier un service/plateforme. Le tag `service` est donc une convention interne à zapwall.fr. Si un NIP standardisé émerge à l'avenir, le système pourra être adapté en conséquence. + +## Avantages du système + +1. **Standardisé** : Tous les événements sont des notes Nostr (kind 1), compatibles avec tous les clients Nostr +2. **Filtrable** : Les tags permettent de filtrer efficacement les événements par type, catégorie et service +3. **Extensible** : Facile d'ajouter de nouveaux types ou catégories +4. **Interopérable** : Les tags sont lisibles par n'importe quel client Nostr, même s'il ne comprend pas la structure complète +5. **Identifiable** : Le tag `service` permet de distinguer les notes zapwall.fr des autres notes Nostr + +## Détection des auteurs + +Les auteurs sont détectés via le tag `#author` dans les événements. Le système souscrit aux événements avec : +- `#author` : Pour identifier les présentations d'auteurs +- `#publication` : Pour identifier les articles + +Cela permet de distinguer les auteurs (présentations) des articles (publications) dans le même flux d'événements. diff --git a/hooks/useArticles.ts b/hooks/useArticles.ts index c327d36..2214710 100644 --- a/hooks/useArticles.ts +++ b/hooks/useArticles.ts @@ -3,6 +3,7 @@ import { nostrService } from '@/lib/nostr' import type { Article } from '@/types/nostr' import { applyFiltersAndSort } from '@/lib/articleFiltering' import type { ArticleFilters } from '@/components/ArticleFilters' +import { t } from '@/lib/i18n' export function useArticles(searchQuery: string = '', filters: ArticleFilters | null = null) { const [articles, setArticles] = useState([]) @@ -32,7 +33,7 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters | const timeout = setTimeout(() => { setLoading(false) if (!hasArticlesRef.current) { - setError('No articles found') + setError(t('common.error.noContent')) } }, 10000) diff --git a/lib/accessControl.ts b/lib/accessControl.ts new file mode 100644 index 0000000..7d6b7fd --- /dev/null +++ b/lib/accessControl.ts @@ -0,0 +1,106 @@ +/** + * Access control rules for objects + * + * Rules: + * - Only the author (pubkey) who published the original note can modify or delete it + * - All users have read access to public content (previews, metadata) + * - For paid content, users must have paid (via zap receipt) to access the full content + * - Payment verification follows the transaction rules (zap receipt validation) + */ + +import type { Event } from 'nostr-tools' +import { extractTagsFromEvent } from './nostrTagSystem' +import { canModifyObject } from './versionManager' + +/** + * Check if a user can modify an object + * Only the author (pubkey) who published the original note can modify it + */ +export function canUserModify(event: Event, userPubkey: string): boolean { + return canModifyObject(event, userPubkey) +} + +/** + * Check if a user can delete an object + * Only the author (pubkey) who published the original note can delete it + */ +export function canUserDelete(event: Event, userPubkey: string): boolean { + return canModifyObject(event, userPubkey) +} + +/** + * Check if a user can read an object + * All users can read public content (previews, metadata) + * For paid content, users must have paid (via zap receipt) to access full content + */ +export function canUserRead(event: Event, userPubkey: string | null, hasPaid: boolean = false): { + canReadPreview: boolean + canReadFullContent: boolean +} { + const tags = extractTagsFromEvent(event) + + // Preview is always readable (public) + const canReadPreview = true + + // Full content access depends on paywall status + if (tags.paywall) { + // Paid content: user must have paid + const canReadFullContent = hasPaid + return { canReadPreview, canReadFullContent } + } + + // Free content: everyone can read + return { canReadPreview, canReadFullContent: true } +} + +/** + * Check if content is paid (has paywall tag) + */ +export function isPaidContent(event: Event): boolean { + const tags = extractTagsFromEvent(event) + return tags.paywall === true +} + +/** + * Access control result + */ +export interface AccessControlResult { + canModify: boolean + canDelete: boolean + canReadPreview: boolean + canReadFullContent: boolean + isPaid: boolean + reason?: string +} + +/** + * Get complete access control information for an object + */ +export function getAccessControl( + event: Event, + userPubkey: string | null, + hasPaid: boolean = false +): AccessControlResult { + const canModify = userPubkey ? canUserModify(event, userPubkey) : false + const canDelete = userPubkey ? canUserDelete(event, userPubkey) : false + const { canReadPreview, canReadFullContent } = canUserRead(event, userPubkey, hasPaid) + const isPaid = isPaidContent(event) + + let reason: string | undefined + if (isPaid && !canReadFullContent) { + reason = 'Payment required to access full content' + } else if (!canModify && userPubkey) { + reason = 'Only the author can modify this object' + } else if (!canDelete && userPubkey) { + reason = 'Only the author can delete this object' + } + + return { + canModify, + canDelete, + canReadPreview, + canReadFullContent, + isPaid, + reason, + } +} diff --git a/lib/articleInvoice.ts b/lib/articleInvoice.ts index 2d197f9..286d7e6 100644 --- a/lib/articleInvoice.ts +++ b/lib/articleInvoice.ts @@ -1,6 +1,8 @@ import { getAlbyService } from './alby' import { calculateArticleSplit, PLATFORM_COMMISSIONS } from './platformCommissions' import { buildTags } from './nostrTagSystem' +import { PLATFORM_SERVICE } from './platformConfig' +import { generatePublicationHashId } from './hashIdGenerator' import type { AlbyInvoice } from '@/types/alby' import type { ArticleDraft } from './articlePublisher' @@ -42,20 +44,21 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise { + const tags = await buildPreviewTags(draft, invoice, authorPubkey, authorPresentationId, extraTags, encryptedKey) return { kind: 1 as const, @@ -65,21 +68,36 @@ export function createPreviewEvent( } } -function buildPreviewTags( +async function buildPreviewTags( draft: ArticleDraft, invoice: AlbyInvoice, + authorPubkey: string, _authorPresentationId?: string, extraTags: string[][] = [], encryptedKey?: string -): string[][] { +): Promise { // Map category to new system const category = draft.category === 'science-fiction' ? 'sciencefiction' : draft.category === 'scientific-research' ? 'research' : 'sciencefiction' + // Generate hash ID from publication data + const hashId = await generatePublicationHashId({ + pubkey: authorPubkey, + title: draft.title, + preview: draft.preview, + category, + seriesId: draft.seriesId ?? undefined, + bannerUrl: draft.bannerUrl ?? undefined, + zapAmount: draft.zapAmount, + }) + // Build tags using new system const newTags = buildTags({ type: 'publication', category, - id: '', // Will be set to event.id after publication + id: hashId, + service: PLATFORM_SERVICE, + version: 0, // New object + hidden: false, paywall: true, // Publications are paid title: draft.title, preview: draft.preview, diff --git a/lib/articleMutations.ts b/lib/articleMutations.ts index c0e97aa..75d73c4 100644 --- a/lib/articleMutations.ts +++ b/lib/articleMutations.ts @@ -2,6 +2,8 @@ import { nostrService } from './nostr' import { createArticleInvoice, createPreviewEvent } from './articleInvoice' import { storePrivateContent, getStoredPrivateContent } from './articleStorage' import { buildTags } from './nostrTagSystem' +import { PLATFORM_SERVICE } from './platformConfig' +import { generateSeriesHashId, generatePublicationHashId } from './hashIdGenerator' import type { ArticleDraft, PublishedArticle } from './articlePublisher' import type { AlbyInvoice } from '@/types/alby' import type { Review, Series } from '@/types/nostr' @@ -36,10 +38,11 @@ async function ensurePresentation(authorPubkey: string): Promise { async function publishPreviewWithInvoice( draft: ArticleDraft, invoice: AlbyInvoice, + authorPubkey: string, presentationId: string, extraTags?: string[][] ): Promise { - const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags) + const previewEvent = await createPreviewEvent(draft, invoice, authorPubkey, presentationId, extraTags) const publishedEvent = await nostrService.publishEvent(previewEvent) return publishedEvent ?? null } @@ -56,7 +59,7 @@ export async function publishSeries(params: { ensureKeys(params.authorPubkey, params.authorPrivateKey) const category = params.category requireCategory(category) - const event = buildSeriesEvent(params, category) + const event = await buildSeriesEvent(params, category) const published = await nostrService.publishEvent(event) if (!published) { throw new Error('Failed to publish series') @@ -73,7 +76,7 @@ export async function publishSeries(params: { } } -function buildSeriesEvent( +async function buildSeriesEvent( params: { title: string description: string @@ -86,6 +89,15 @@ function buildSeriesEvent( // Map category to new system const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' + // Generate hash ID from series data + const hashId = await generateSeriesHashId({ + pubkey: params.authorPubkey, + title: params.title, + description: params.description, + category: newCategory, + coverUrl: params.coverUrl ?? undefined, + }) + return { kind: 1, created_at: Math.floor(Date.now() / 1000), @@ -93,7 +105,10 @@ function buildSeriesEvent( tags: buildTags({ type: 'series', category: newCategory, - id: '', // Will be set to event.id after publication + id: hashId, + service: PLATFORM_SERVICE, + version: 0, // New object + hidden: false, paywall: false, title: params.title, description: params.description, @@ -116,7 +131,7 @@ export async function publishReview(params: { ensureKeys(params.reviewerPubkey, params.authorPrivateKey) const category = params.category requireCategory(category) - const event = buildReviewEvent(params, category) + const event = await buildReviewEvent(params, category) const published = await nostrService.publishEvent(event) if (!published) { throw new Error('Failed to publish review') @@ -133,7 +148,7 @@ export async function publishReview(params: { } } -function buildReviewEvent( +async function buildReviewEvent( params: { articleId: string seriesId: string @@ -147,6 +162,16 @@ function buildReviewEvent( // Map category to new system const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' + // Generate hash ID from review data + const { generateReviewHashId } = await import('./hashIdGenerator') + const hashId = await generateReviewHashId({ + pubkey: params.reviewerPubkey, + articleId: params.articleId, + reviewerPubkey: params.reviewerPubkey, + content: params.content, + title: params.title ?? undefined, + }) + return { kind: 1, created_at: Math.floor(Date.now() / 1000), @@ -154,7 +179,10 @@ function buildReviewEvent( tags: buildTags({ type: 'quote', category: newCategory, - id: '', // Will be set to event.id after publication + id: hashId, + service: PLATFORM_SERVICE, + version: 0, // New object + hidden: false, paywall: false, articleId: params.articleId, reviewerPubkey: params.reviewerPubkey, @@ -163,11 +191,34 @@ function buildReviewEvent( } } -function buildUpdateTags(draft: ArticleDraft, originalArticleId: string, newCategory: 'sciencefiction' | 'research') { +async function buildUpdateTags( + draft: ArticleDraft, + originalArticleId: string, + newCategory: 'sciencefiction' | 'research', + authorPubkey: string, + currentVersion: number = 0 +) { + // Generate hash ID from publication data + const hashId = await generatePublicationHashId({ + pubkey: authorPubkey, + title: draft.title, + preview: draft.preview, + category: newCategory, + seriesId: draft.seriesId ?? undefined, + bannerUrl: draft.bannerUrl ?? undefined, + zapAmount: draft.zapAmount, + }) + + // Increment version for update + const nextVersion = currentVersion + 1 + const updateTags = buildTags({ type: 'publication', category: newCategory, - id: '', // Will be set to event.id after publication + id: hashId, + service: PLATFORM_SERVICE, + version: nextVersion, + hidden: false, paywall: true, title: draft.title, preview: draft.preview, @@ -189,9 +240,9 @@ async function publishUpdate( const presentationId = await ensurePresentation(authorPubkey) const invoice = await createArticleInvoice(draft) const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research' - const updateTags = buildUpdateTags(draft, originalArticleId, newCategory) + const updateTags = await buildUpdateTags(draft, originalArticleId, newCategory, authorPubkey) - const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags) + const publishedEvent = await publishPreviewWithInvoice(draft, invoice, authorPubkey, presentationId, updateTags) if (!publishedEvent) { return updateFailure(originalArticleId, 'Failed to publish article update') } diff --git a/lib/articlePublisher.ts b/lib/articlePublisher.ts index a452fed..6e17a72 100644 --- a/lib/articlePublisher.ts +++ b/lib/articlePublisher.ts @@ -171,9 +171,12 @@ export class ArticlePublisher { nostrService.setPublicKey(authorPubkey) nostrService.setPrivateKey(authorPrivateKey) - // Generate event ID before building event (using a temporary ID that will be replaced by Nostr) - const tempEventId = `temp_${Math.random().toString(36).substring(7)}` - const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction')) + // Extract author name from title (format: "Présentation de ") + const authorName = draft.title.replace(/^Présentation de /, '').trim() || 'Auteur' + + // Build event with hash-based ID + const eventTemplate = await buildPresentationEvent(draft, authorPubkey, authorName, 'sciencefiction') + const publishedEvent = await nostrService.publishEvent(eventTemplate) if (!publishedEvent) { return buildFailure('Failed to publish presentation article') diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts index f7ed475..67d45b5 100644 --- a/lib/articlePublisherHelpersPresentation.ts +++ b/lib/articlePublisherHelpersPresentation.ts @@ -1,25 +1,92 @@ import { type Event } from 'nostr-tools' +import { nip19 } from 'nostr-tools' import type { AuthorPresentationDraft } from './articlePublisher' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem' import { getPrimaryRelaySync } from './config' +import { PLATFORM_SERVICE } from './platformConfig' +import { generateAuthorHashId } from './hashIdGenerator' +import { generateObjectUrl } from './urlGenerator' +import { getLatestVersion } from './versionManager' + +export async function buildPresentationEvent( + draft: AuthorPresentationDraft, + authorPubkey: string, + authorName: string, + category: 'sciencefiction' | 'research' = 'sciencefiction', + version: number = 0, + index: number = 0 +) { + // Extract presentation and contentDescription from draft.content + // Format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}" + const separator = '\n\n---\n\nDescription du contenu :\n' + const separatorIndex = draft.content.indexOf(separator) + const presentation = separatorIndex !== -1 ? draft.content.substring(0, separatorIndex) : draft.presentation + const contentDescription = separatorIndex !== -1 ? draft.content.substring(separatorIndex + separator.length) : draft.contentDescription + + // Generate hash ID from author data first (needed for URL) + const hashId = await generateAuthorHashId({ + pubkey: authorPubkey, + authorName, + presentation, + contentDescription, + mainnetAddress: draft.mainnetAddress ?? undefined, + pictureUrl: draft.pictureUrl ?? undefined, + category, + }) + + // Build URL: https://zapwall.fr/author/__ + const profileUrl = generateObjectUrl('author', hashId, index, version) + + // Build visible content message + const visibleContent = [ + 'Nouveau profil publié sur zapwall.fr', + profileUrl, + ...(draft.pictureUrl ? [draft.pictureUrl] : []), + `Présentation personnelle : ${presentation}`, + `Description de votre contenu : ${contentDescription}`, + `Adresse Bitcoin mainnet (pour le sponsoring) : ${draft.mainnetAddress}`, + ].join('\n') + + // Build profile JSON for metadata (non-visible) + const profileJson = JSON.stringify({ + authorName, + npub, + pubkey: authorPubkey, + presentation, + contentDescription, + mainnetAddress: draft.mainnetAddress, + pictureUrl: draft.pictureUrl, + category, + url: profileUrl, + version, + index, + }, null, 2) + + // Combine visible content and JSON metadata (JSON in hidden section) + const fullContent = `${visibleContent}\n\n---\n\n[Metadata JSON]\n${profileJson}` + + // Build tags (profile JSON is in content, not in tags) + const tags = buildTags({ + type: 'author', + category, + id: hashId, + service: PLATFORM_SERVICE, + version, + hidden: false, + paywall: false, + title: draft.title, + preview: draft.preview, + mainnetAddress: draft.mainnetAddress, + totalSponsoring: 0, + ...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}), + }) -export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: string, category: 'sciencefiction' | 'research' = 'sciencefiction') { return { kind: 1 as const, created_at: Math.floor(Date.now() / 1000), - tags: buildTags({ - type: 'author', - category, - id: eventId, - paywall: false, - title: draft.title, - preview: draft.preview, - mainnetAddress: draft.mainnetAddress, - totalSponsoring: 0, - ...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}), - }), - content: draft.content, + tags, + content: fullContent, } } @@ -31,10 +98,27 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au return null } + // Try to extract profile JSON from content [Metadata JSON] section + let profileData: { + presentation?: string + contentDescription?: string + mainnetAddress?: string + pictureUrl?: string + } | null = null + + const jsonMatch = event.content.match(/\[Metadata JSON\]\n(.+)$/s) + if (jsonMatch && jsonMatch[1]) { + try { + profileData = JSON.parse(jsonMatch[1].trim()) + } catch (e) { + console.error('Error parsing profile JSON from content:', e) + } + } + // Map tag category to article category const articleCategory = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined - return { + const result: import('@/types/nostr').AuthorPresentationArticle = { id: tags.id ?? event.id, pubkey: event.pubkey, title: tags.title ?? 'Présentation', @@ -45,11 +129,19 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au paid: true, category: 'author-presentation', isPresentation: true, - mainnetAddress: tags.mainnetAddress ?? '', + mainnetAddress: profileData?.mainnetAddress ?? tags.mainnetAddress ?? '', totalSponsoring: tags.totalSponsoring ?? 0, - originalCategory: articleCategory, // Store original category for filtering - ...(tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string' ? { bannerUrl: tags.pictureUrl } : {}), + originalCategory: articleCategory ?? 'science-fiction', // Store original category for filtering } + + // Add bannerUrl if available + if (profileData?.pictureUrl !== undefined && profileData?.pictureUrl !== null) { + result.bannerUrl = profileData.pictureUrl + } else if (tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string') { + result.bannerUrl = tags.pictureUrl + } + + return result } export function fetchAuthorPresentationFromPool( @@ -61,8 +153,9 @@ export function fetchAuthorPresentationFromPool( ...buildTagFilter({ type: 'author', authorPubkey: pubkey, + service: PLATFORM_SERVICE, }), - limit: 1, + limit: 100, // Get all versions to find the latest }, ] @@ -72,6 +165,8 @@ export function fetchAuthorPresentationFromPool( const { createSubscription } = require('@/types/nostr-tools-extended') const sub = createSubscription(pool, [relayUrl], filters) + const events: Event[] = [] + const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => { if (resolved) { return @@ -82,13 +177,36 @@ export function fetchAuthorPresentationFromPool( } sub.on('event', (event: Event) => { - const parsed = parsePresentationEvent(event) - if (parsed) { - finalize(parsed) + // Collect all events first + const tags = extractTagsFromEvent(event) + if (tags.type === 'author' && !tags.hidden) { + events.push(event) } }) - sub.on('eose', () => finalize(null)) - setTimeout(() => finalize(null), 5000) + sub.on('eose', () => { + // Get the latest version from all collected events + const latestEvent = getLatestVersion(events) + if (latestEvent) { + const parsed = parsePresentationEvent(latestEvent) + if (parsed) { + finalize(parsed) + return + } + } + finalize(null) + }) + setTimeout(() => { + // Get the latest version from all collected events + const latestEvent = getLatestVersion(events) + if (latestEvent) { + const parsed = parsePresentationEvent(latestEvent) + if (parsed) { + finalize(parsed) + return + } + } + finalize(null) + }, 5000).unref?.() }) } diff --git a/lib/articlePublisherPublish.ts b/lib/articlePublisherPublish.ts index bb3826d..a875209 100644 --- a/lib/articlePublisherPublish.ts +++ b/lib/articlePublisherPublish.ts @@ -17,12 +17,13 @@ export function buildFailure(error?: string): PublishedArticle { export async function publishPreview( draft: ArticleDraft, invoice: AlbyInvoice, + authorPubkey: string, presentationId: string, extraTags?: string[][], encryptedContent?: string, encryptedKey?: string ): Promise { - const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey) + const previewEvent = await createPreviewEvent(draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey) const publishedEvent = await nostrService.publishEvent(previewEvent) return publishedEvent ?? null } @@ -49,7 +50,7 @@ export async function encryptAndPublish( const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey) const invoice = await createArticleInvoice(draft) const extraTags = buildArticleExtraTags(draft, category) - const publishedEvent = await publishPreview(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey) + const publishedEvent = await publishPreview(draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey) if (!publishedEvent) { return buildFailure('Failed to publish article') diff --git a/lib/articleQueries.ts b/lib/articleQueries.ts index 5db6056..1fb8ad7 100644 --- a/lib/articleQueries.ts +++ b/lib/articleQueries.ts @@ -6,6 +6,7 @@ import type { Article } from '@/types/nostr' import { parseArticleFromEvent } from './nostrEventParsing' import { buildTagFilter } from './nostrTagSystem' import { getPrimaryRelaySync } from './config' +import { PLATFORM_SERVICE } from './platformConfig' function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: number) { const filters = [ @@ -13,6 +14,7 @@ function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: num ...buildTagFilter({ type: 'publication', seriesId, + service: PLATFORM_SERVICE, }), limit, }, diff --git a/lib/duplicateDetector.ts b/lib/duplicateDetector.ts new file mode 100644 index 0000000..2b8bfc6 --- /dev/null +++ b/lib/duplicateDetector.ts @@ -0,0 +1,124 @@ +/** + * Detect duplicate IDs for the same object type + * When duplicates are found, warn the user and ask them to choose + */ + +import type { ExtractedObject } from './metadataExtractor' + +export interface DuplicateGroup { + id: string + type: T['type'] + objects: T[] +} + +export interface DuplicateWarning { + type: ExtractedObject['type'] + id: string + objects: ExtractedObject[] + message: string +} + +/** + * Group objects by type and ID to detect duplicates + */ +export function detectDuplicates(objects: ExtractedObject[]): DuplicateWarning[] { + const warnings: DuplicateWarning[] = [] + + // Group objects by type + const byType = new Map() + for (const obj of objects) { + if (!byType.has(obj.type)) { + byType.set(obj.type, []) + } + byType.get(obj.type)!.push(obj) + } + + // For each type, group by ID + for (const [type, typeObjects] of byType.entries()) { + const byId = new Map() + for (const obj of typeObjects) { + if (!byId.has(obj.id)) { + byId.set(obj.id, []) + } + byId.get(obj.id)!.push(obj) + } + + // Check for duplicates (same ID, multiple objects) + for (const [id, idObjects] of byId.entries()) { + if (idObjects.length > 1) { + warnings.push({ + type, + id, + objects: idObjects, + message: `Found ${idObjects.length} objects of type "${type}" with the same ID "${id.substring(0, 16)}...". Please choose which one to keep.`, + }) + } + } + } + + return warnings +} + +/** + * Resolve duplicates by keeping only the first object for each ID + * This is a simple resolution - in production, you'd want user interaction + */ +export function resolveDuplicatesSimple(objects: ExtractedObject[]): ExtractedObject[] { + const seen = new Map() + const resolved: ExtractedObject[] = [] + + for (const obj of objects) { + const key = `${obj.type}:${obj.id}` + if (!seen.has(key)) { + seen.set(key, obj) + resolved.push(obj) + } else { + // Keep the first one, skip duplicates + console.warn(`Duplicate detected for ${obj.type} with ID ${obj.id.substring(0, 16)}... Keeping first occurrence.`) + } + } + + return resolved +} + +/** + * Resolve duplicates by keeping the most recent object (highest event.created_at) + */ +export function resolveDuplicatesByDate(objects: ExtractedObject[]): ExtractedObject[] { + const byKey = new Map() + + // Group by type and ID + for (const obj of objects) { + const key = `${obj.type}:${obj.id}` + if (!byKey.has(key)) { + byKey.set(key, []) + } + byKey.get(key)!.push(obj) + } + + const resolved: ExtractedObject[] = [] + + for (const [key, group] of byKey.entries()) { + if (group.length === 1) { + const obj = group[0] + if (obj) { + resolved.push(obj) + } + } else { + // Sort by eventId (which should correlate with creation time) + // Keep the one with the "latest" eventId (lexicographically) + // In practice, you'd want to fetch the actual created_at from events + group.sort((a, b) => { + // Simple lexicographic comparison - in production, compare actual timestamps + return b.eventId.localeCompare(a.eventId) + }) + const first = group[0] + if (first) { + resolved.push(first) + console.warn(`Resolved ${group.length} duplicates for ${key} by keeping most recent`) + } + } + } + + return resolved +} diff --git a/lib/hashIdGenerator.ts b/lib/hashIdGenerator.ts new file mode 100644 index 0000000..398ddef --- /dev/null +++ b/lib/hashIdGenerator.ts @@ -0,0 +1,199 @@ +/** + * Hash-based ID generation for Nostr objects + * All IDs are SHA-256 hashes of the object's canonical representation + */ + +/** + * Generate a canonical string representation of an object for hashing + * This ensures that the same object always produces the same hash + */ +function canonicalizeObject(obj: Record): string { + // Sort keys to ensure consistent ordering + const sortedKeys = Object.keys(obj).sort() + const parts: string[] = [] + + for (const key of sortedKeys) { + const value = obj[key] + if (value === undefined || value === null) { + continue // Skip undefined/null values + } + if (typeof value === 'object' && !Array.isArray(value)) { + // Recursively canonicalize nested objects + parts.push(`${key}:${canonicalizeObject(value as Record)}`) + } else if (Array.isArray(value)) { + // Arrays are serialized as comma-separated values + parts.push(`${key}:[${value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : String(v))).join(',')}]`) + } else { + parts.push(`${key}:${String(value)}`) + } + } + + return `{${parts.join('|')}}` +} + +/** + * Generate a SHA-256 hash ID from an object using Web Crypto API + * The hash is deterministic: the same object always produces the same hash + */ +export async function generateHashId(obj: Record): Promise { + const canonical = canonicalizeObject(obj) + const encoder = new TextEncoder() + const data = encoder.encode(canonical) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') +} + +/** + * Generate hash ID for an author presentation + */ +export async function generateAuthorHashId(authorData: { + pubkey: string + authorName: string + presentation: string + contentDescription: string + mainnetAddress?: string | undefined + pictureUrl?: string | undefined + category: string +}): Promise { + return generateHashId({ + type: 'author', + pubkey: authorData.pubkey, + authorName: authorData.authorName, + presentation: authorData.presentation, + contentDescription: authorData.contentDescription, + mainnetAddress: authorData.mainnetAddress ?? '', + pictureUrl: authorData.pictureUrl ?? '', + category: authorData.category, + }) +} + +/** + * Generate hash ID for a series + */ +export async function generateSeriesHashId(seriesData: { + pubkey: string + title: string + description: string + category: string + coverUrl?: string | undefined +}): Promise { + return generateHashId({ + type: 'series', + pubkey: seriesData.pubkey, + title: seriesData.title, + description: seriesData.description, + category: seriesData.category, + coverUrl: seriesData.coverUrl ?? '', + }) +} + +/** + * Generate hash ID for a publication + */ +export async function generatePublicationHashId(publicationData: { + pubkey: string + title: string + preview: string + category: string + seriesId?: string | undefined + bannerUrl?: string | undefined + zapAmount: number +}): Promise { + return generateHashId({ + type: 'publication', + pubkey: publicationData.pubkey, + title: publicationData.title, + preview: publicationData.preview, + category: publicationData.category, + seriesId: publicationData.seriesId ?? '', + bannerUrl: publicationData.bannerUrl ?? '', + zapAmount: publicationData.zapAmount, + }) +} + +/** + * Generate hash ID for a review/quote + */ +export async function generateReviewHashId(reviewData: { + pubkey: string + articleId: string + reviewerPubkey: string + content: string + title?: string +}): Promise { + return generateHashId({ + type: 'quote', + pubkey: reviewData.pubkey, + articleId: reviewData.articleId, + reviewerPubkey: reviewData.reviewerPubkey, + content: reviewData.content, + title: reviewData.title ?? '', + }) +} + +/** + * Generate hash ID for a purchase (zap receipt kind 9735) + */ +export async function generatePurchaseHashId(purchaseData: { + payerPubkey: string + articleId: string + authorPubkey: string + amount: number + paymentHash: string +}): Promise { + return generateHashId({ + type: 'purchase', + payerPubkey: purchaseData.payerPubkey, + articleId: purchaseData.articleId, + authorPubkey: purchaseData.authorPubkey, + amount: purchaseData.amount, + paymentHash: purchaseData.paymentHash, + }) +} + +/** + * Generate hash ID for a review tip (zap receipt kind 9735) + */ +export async function generateReviewTipHashId(tipData: { + payerPubkey: string + articleId: string + reviewId: string + reviewerPubkey: string + authorPubkey: string + amount: number + paymentHash: string +}): Promise { + return generateHashId({ + type: 'review_tip', + payerPubkey: tipData.payerPubkey, + articleId: tipData.articleId, + reviewId: tipData.reviewId, + reviewerPubkey: tipData.reviewerPubkey, + authorPubkey: tipData.authorPubkey, + amount: tipData.amount, + paymentHash: tipData.paymentHash, + }) +} + +/** + * Generate hash ID for a sponsoring (zap receipt kind 9735) + */ +export async function generateSponsoringHashId(sponsoringData: { + payerPubkey: string + authorPubkey: string + seriesId?: string + articleId?: string + amount: number + paymentHash: string +}): Promise { + return generateHashId({ + type: 'sponsoring', + payerPubkey: sponsoringData.payerPubkey, + authorPubkey: sponsoringData.authorPubkey, + seriesId: sponsoringData.seriesId ?? '', + articleId: sponsoringData.articleId ?? '', + amount: sponsoringData.amount, + paymentHash: sponsoringData.paymentHash, + }) +} diff --git a/lib/keyManagement.ts b/lib/keyManagement.ts index 7272744..ba1a43e 100644 --- a/lib/keyManagement.ts +++ b/lib/keyManagement.ts @@ -42,7 +42,7 @@ export class KeyManagementService { if (decoded.type === 'nsec') { // decoded.data can be string (hex) or Uint8Array depending on nostr-tools version if (typeof decoded.data === 'string') { - privateKeyHex = decoded.data + privateKeyHex = decoded.data } else if (decoded.data instanceof Uint8Array) { privateKeyHex = bytesToHex(decoded.data) } else { diff --git a/lib/metadataExtractor.ts b/lib/metadataExtractor.ts new file mode 100644 index 0000000..a546f6e --- /dev/null +++ b/lib/metadataExtractor.ts @@ -0,0 +1,491 @@ +/** + * Extract objects from invisible metadata in Nostr notes + * Objects are stored in [Metadata JSON] sections in the note content + */ + +import type { Event } from 'nostr-tools' +import { extractTagsFromEvent } from './nostrTagSystem' +import { generateAuthorHashId, generateSeriesHashId, generatePublicationHashId, generateReviewHashId, generatePurchaseHashId, generateReviewTipHashId, generateSponsoringHashId } from './hashIdGenerator' + +export interface ExtractedAuthor { + type: 'author' + id: string + pubkey: string + authorName: string + presentation: string + contentDescription: string + mainnetAddress?: string + pictureUrl?: string + category: string + url?: string + eventId: string +} + +export interface ExtractedSeries { + type: 'series' + id: string + pubkey: string + title: string + description: string + preview?: string + coverUrl?: string + category: string + eventId: string +} + +export interface ExtractedPublication { + type: 'publication' + id: string + pubkey: string + title: string + preview: string + category: string + seriesId?: string + bannerUrl?: string + zapAmount: number + eventId: string +} + +export interface ExtractedReview { + type: 'review' + id: string + pubkey: string + articleId: string + reviewerPubkey: string + content: string + title?: string + eventId: string +} + +export interface ExtractedPurchase { + type: 'purchase' + id: string + payerPubkey: string + articleId: string + authorPubkey: string + amount: number + paymentHash: string + eventId: string +} + +export interface ExtractedReviewTip { + type: 'review_tip' + id: string + payerPubkey: string + articleId: string + reviewId: string + reviewerPubkey: string + authorPubkey: string + amount: number + paymentHash: string + eventId: string +} + +export interface ExtractedSponsoring { + type: 'sponsoring' + id: string + payerPubkey: string + authorPubkey: string + seriesId?: string + articleId?: string + amount: number + paymentHash: string + eventId: string +} + +export type ExtractedObject = + | ExtractedAuthor + | ExtractedSeries + | ExtractedPublication + | ExtractedReview + | ExtractedPurchase + | ExtractedReviewTip + | ExtractedSponsoring + +/** + * Extract JSON metadata from note content + */ +function extractMetadataJson(content: string): Record | null { + const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s) + if (jsonMatch && jsonMatch[1]) { + try { + return JSON.parse(jsonMatch[1].trim()) + } catch (e) { + console.error('Error parsing metadata JSON from content:', e) + return null + } + } + return null +} + +/** + * Extract author from event + */ +export async function extractAuthorFromEvent(event: Event): Promise { + const tags = extractTagsFromEvent(event) + if (tags.type !== 'author') { + return null + } + + // Try to extract from metadata JSON first + const metadata = extractMetadataJson(event.content) + if (metadata && metadata.type === 'author') { + const authorData = { + pubkey: (metadata.pubkey as string) ?? event.pubkey, + authorName: (metadata.authorName as string) ?? '', + presentation: (metadata.presentation as string) ?? '', + contentDescription: (metadata.contentDescription as string) ?? '', + mainnetAddress: metadata.mainnetAddress as string | undefined, + pictureUrl: metadata.pictureUrl as string | undefined, + category: (metadata.category as string) ?? tags.category ?? 'sciencefiction', + } + + const id = await generateAuthorHashId(authorData) + + return { + type: 'author', + id, + ...authorData, + eventId: event.id, + url: metadata.url as string | undefined, + } + } + + // Fallback: extract from tags and visible content + // This is a simplified extraction - full data should be in metadata JSON + return null +} + +/** + * Extract series from event + */ +export async function extractSeriesFromEvent(event: Event): Promise { + const tags = extractTagsFromEvent(event) + if (tags.type !== 'series') { + return null + } + + const metadata = extractMetadataJson(event.content) + if (metadata && metadata.type === 'series') { + const seriesData = { + pubkey: (metadata.pubkey as string) ?? event.pubkey, + title: (metadata.title as string) ?? (tags.title as string) ?? '', + description: (metadata.description as string) ?? '', + preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200), + coverUrl: (metadata.coverUrl as string) ?? (tags.coverUrl as string) ?? undefined, + category: (metadata.category as string) ?? tags.category ?? 'sciencefiction', + } + + const id = await generateSeriesHashId(seriesData) + + return { + type: 'series', + id, + ...seriesData, + eventId: event.id, + } + } + + // Fallback: extract from tags + if (tags.title && tags.description) { + const seriesData = { + pubkey: event.pubkey, + title: tags.title as string, + description: tags.description as string, + preview: (tags.preview as string) ?? event.content.substring(0, 200), + coverUrl: tags.coverUrl as string | undefined, + category: tags.category ?? 'sciencefiction', + } + + const id = await generateSeriesHashId(seriesData) + + return { + type: 'series', + id, + ...seriesData, + eventId: event.id, + } + } + + return null +} + +/** + * Extract publication from event + */ +export async function extractPublicationFromEvent(event: Event): Promise { + const tags = extractTagsFromEvent(event) + if (tags.type !== 'publication') { + return null + } + + const metadata = extractMetadataJson(event.content) + if (metadata && metadata.type === 'publication') { + const publicationData = { + pubkey: (metadata.pubkey as string) ?? event.pubkey, + title: (metadata.title as string) ?? (tags.title as string) ?? '', + preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200), + category: (metadata.category as string) ?? tags.category ?? 'sciencefiction', + seriesId: (metadata.seriesId as string) ?? tags.seriesId ?? undefined, + bannerUrl: (metadata.bannerUrl as string) ?? tags.bannerUrl ?? undefined, + zapAmount: (metadata.zapAmount as number) ?? tags.zapAmount ?? 800, + } + + const id = await generatePublicationHashId(publicationData) + + return { + type: 'publication', + id, + ...publicationData, + eventId: event.id, + } + } + + // Fallback: extract from tags + if (tags.title) { + const publicationData = { + pubkey: event.pubkey, + title: tags.title as string, + preview: (tags.preview as string) ?? event.content.substring(0, 200), + category: tags.category ?? 'sciencefiction', + seriesId: tags.seriesId as string | undefined, + bannerUrl: tags.bannerUrl as string | undefined, + zapAmount: tags.zapAmount ?? 800, + } + + const id = await generatePublicationHashId(publicationData) + + return { + type: 'publication', + id, + ...publicationData, + eventId: event.id, + } + } + + return null +} + +/** + * Extract review from event + */ +export async function extractReviewFromEvent(event: Event): Promise { + const tags = extractTagsFromEvent(event) + if (tags.type !== 'quote') { + return null + } + + const metadata = extractMetadataJson(event.content) + if (metadata && metadata.type === 'review') { + const reviewData = { + pubkey: (metadata.pubkey as string) ?? event.pubkey, + articleId: (metadata.articleId as string) ?? (tags.articleId as string) ?? '', + reviewerPubkey: (metadata.reviewerPubkey as string) ?? (tags.reviewerPubkey as string) ?? event.pubkey, + content: (metadata.content as string) ?? event.content, + title: (metadata.title as string) ?? (tags.title as string) ?? undefined, + } + + if (!reviewData.articleId || !reviewData.reviewerPubkey) { + return null + } + + const id = await generateReviewHashId(reviewData) + + return { + type: 'review', + id, + ...reviewData, + eventId: event.id, + } + } + + // Fallback: extract from tags + if (tags.articleId && tags.reviewerPubkey) { + const reviewData = { + pubkey: event.pubkey, + articleId: tags.articleId as string, + reviewerPubkey: tags.reviewerPubkey as string, + content: event.content, + title: tags.title as string | undefined, + } + + const id = await generateReviewHashId(reviewData) + + return { + type: 'review', + id, + ...reviewData, + eventId: event.id, + } + } + + return null +} + +/** + * Extract purchase from zap receipt (kind 9735) + */ +export async function extractPurchaseFromEvent(event: Event): Promise { + if (event.kind !== 9735) { + return null + } + + // Check for purchase kind_type tag + const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'purchase') + if (!kindTypeTag) { + return null + } + + const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1] + const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1] + const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] + const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1] + + if (!pTag || !eTag || !amountTag) { + return null + } + + const amount = parseInt(amountTag, 10) / 1000 // Convert millisats to sats + const paymentHash = paymentHashTag ?? event.id // Use event.id as fallback + + const purchaseData = { + payerPubkey: event.pubkey, + articleId: eTag, + authorPubkey: pTag, + amount, + paymentHash, + } + + const id = await generatePurchaseHashId(purchaseData) + + return { + type: 'purchase', + id, + ...purchaseData, + eventId: event.id, + } +} + +/** + * Extract review tip from zap receipt (kind 9735) + */ +export async function extractReviewTipFromEvent(event: Event): Promise { + if (event.kind !== 9735) { + return null + } + + const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'review_tip') + if (!kindTypeTag) { + return null + } + + const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1] + const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1] + const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] + const reviewerTag = event.tags.find((tag) => tag[0] === 'reviewer')?.[1] + const reviewIdTag = event.tags.find((tag) => tag[0] === 'review_id')?.[1] + const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1] + + if (!pTag || !eTag || !amountTag || !reviewerTag || !reviewIdTag) { + return null + } + + const amount = parseInt(amountTag, 10) / 1000 + const paymentHash = paymentHashTag ?? event.id + + const tipData = { + payerPubkey: event.pubkey, + articleId: eTag, + reviewId: reviewIdTag, + reviewerPubkey: reviewerTag, + authorPubkey: pTag, + amount, + paymentHash, + } + + const id = await generateReviewTipHashId(tipData) + + return { + type: 'review_tip', + id, + ...tipData, + eventId: event.id, + } +} + +/** + * Extract sponsoring from zap receipt (kind 9735) + */ +export async function extractSponsoringFromEvent(event: Event): Promise { + if (event.kind !== 9735) { + return null + } + + const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'sponsoring') + if (!kindTypeTag) { + return null + } + + const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1] + const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1] + const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1] + const seriesTag = event.tags.find((tag) => tag[0] === 'series')?.[1] + const articleTag = event.tags.find((tag) => tag[0] === 'article')?.[1] + const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1] + + if (!pTag || !amountTag) { + return null + } + + const amount = parseInt(amountTag, 10) / 1000 + const paymentHash = paymentHashTag ?? event.id + + const sponsoringData = { + payerPubkey: event.pubkey, + authorPubkey: pTag, + seriesId: seriesTag, + articleId: articleTag ?? eTag, // Use eTag as fallback for articleId + amount, + paymentHash, + } + + const id = await generateSponsoringHashId(sponsoringData) + + return { + type: 'sponsoring', + id, + ...sponsoringData, + eventId: event.id, + } +} + +/** + * Extract all objects from an event + */ +export async function extractObjectsFromEvent(event: Event): Promise { + const results: ExtractedObject[] = [] + + // Try to extract each type + const author = await extractAuthorFromEvent(event) + if (author) results.push(author) + + const series = await extractSeriesFromEvent(event) + if (series) results.push(series) + + const publication = await extractPublicationFromEvent(event) + if (publication) results.push(publication) + + const review = await extractReviewFromEvent(event) + if (review) results.push(review) + + const purchase = await extractPurchaseFromEvent(event) + if (purchase) results.push(purchase) + + const reviewTip = await extractReviewTipFromEvent(event) + if (reviewTip) results.push(reviewTip) + + const sponsoring = await extractSponsoringFromEvent(event) + if (sponsoring) results.push(sponsoring) + + return results +} diff --git a/lib/nostr.ts b/lib/nostr.ts index 9f7cd94..f734873 100644 --- a/lib/nostr.ts +++ b/lib/nostr.ts @@ -3,6 +3,7 @@ import { hexToBytes } from 'nostr-tools/utils' import type { Article, NostrProfile } from '@/types/nostr' import { createSubscription } from '@/types/nostr-tools-extended' import { parseArticleFromEvent } from './nostrEventParsing' +import { parsePresentationEvent } from './articlePublisherHelpersPresentation' import { getPrivateContent as getPrivateContentFromPool, getDecryptionKey, @@ -12,6 +13,7 @@ import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification import { subscribeWithTimeout } from './nostrSubscription' import { getPrimaryRelay, getPrimaryRelaySync } from './config' import { buildTagFilter } from './nostrTagSystem' +import { PLATFORM_SERVICE } from './platformConfig' class NostrService { private pool: SimplePool | null = null @@ -84,10 +86,21 @@ class NostrService { } private createArticleSubscription(pool: SimplePool, limit: number) { + // Subscribe to both 'publication' and 'author' type events + // Authors are identified by tag type='author' in the tag system + // Filter by service='zapwall.fr' to only get notes from this platform const filters = [ { ...buildTagFilter({ type: 'publication', + service: PLATFORM_SERVICE, + }), + limit, + }, + { + ...buildTagFilter({ + type: 'author', + service: PLATFORM_SERVICE, }), limit, }, @@ -113,7 +126,15 @@ class NostrService { sub.on('event', (event: Event) => { try { - const article = parseArticleFromEvent(event) + // Try to parse as regular article first + let article = parseArticleFromEvent(event) + // If not a regular article, try to parse as author presentation + if (!article) { + const presentation = parsePresentationEvent(event) + if (presentation) { + article = presentation + } + } if (article) { callback(article) } diff --git a/lib/nostrTagSystemBuild.ts b/lib/nostrTagSystemBuild.ts index a804c11..70d6a8c 100644 --- a/lib/nostrTagSystemBuild.ts +++ b/lib/nostrTagSystemBuild.ts @@ -5,6 +5,13 @@ function buildBaseTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTa result.push([tags.type]) result.push([tags.category]) result.push(['id', tags.id]) + result.push(['service', tags.service]) + // Add version tag (default to 0 if not specified) + result.push(['version', (tags.version ?? 0).toString()]) + // Add hidden tag only if true + if (tags.hidden === true) { + result.push(['hidden', 'true']) + } if (tags.paywall) { result.push(['paywall']) } diff --git a/lib/nostrTagSystemExtract.ts b/lib/nostrTagSystemExtract.ts index ac2dbba..a948dcd 100644 --- a/lib/nostrTagSystemExtract.ts +++ b/lib/nostrTagSystemExtract.ts @@ -21,6 +21,9 @@ export function extractTypeAndCategory(event: { tags: string[][] }): { type?: Ta export function extractCommonTags(findTag: (key: string) => string | undefined, hasTag: (key: string) => boolean) { return { id: findTag('id'), + service: findTag('service'), + version: parseNumericTag(findTag, 'version') ?? 0, // Default to 0 if not present + hidden: findTag('hidden') === 'true', // true only if tag exists and value is 'true' paywall: hasTag('paywall'), payment: hasTag('payment'), title: findTag('title'), @@ -45,6 +48,9 @@ export function extractTagsFromEvent(event: { tags: string[][] }): { type?: TagType | undefined category?: TagCategory | undefined id?: string | undefined + service?: string | undefined + version: number + hidden: boolean paywall: boolean payment: boolean title?: string | undefined diff --git a/lib/nostrTagSystemFilter.ts b/lib/nostrTagSystemFilter.ts index 0c716f9..8c8f336 100644 --- a/lib/nostrTagSystemFilter.ts +++ b/lib/nostrTagSystemFilter.ts @@ -16,6 +16,7 @@ export function buildTagFilter(params: { type?: TagType category?: TagCategory id?: string + service?: string paywall?: boolean payment?: boolean seriesId?: string @@ -33,6 +34,7 @@ export function buildTagFilter(params: { filter[`#${params.category}`] = [''] } addValueTagFilter(filter, 'id', params.id) + addValueTagFilter(filter, 'service', params.service) addSimpleTagFilter(filter, 'paywall', params.paywall === true) addSimpleTagFilter(filter, 'payment', params.payment === true) addValueTagFilter(filter, 'series', params.seriesId) diff --git a/lib/nostrTagSystemTypes.ts b/lib/nostrTagSystemTypes.ts index 6c5be2a..7af8f2e 100644 --- a/lib/nostrTagSystemTypes.ts +++ b/lib/nostrTagSystemTypes.ts @@ -4,6 +4,7 @@ * - #sciencefiction or #research: for category * - #author, #series, #publication, #quote: for type * - #id_: for identifier + * - #service: service identifier (e.g., "zapwall.fr") to filter all notes from this platform * - #payment (optional): for payment notes * * Everything is a Nostr note (kind 1) @@ -17,6 +18,9 @@ export interface BaseTags { type: TagType category: TagCategory id: string + service: string // Service identifier (e.g., "zapwall.fr") + version?: number // Version number (0 by default, incremented on updates) + hidden?: boolean // Hidden flag (true to hide/delete, false or undefined to show) paywall?: boolean payment?: boolean } diff --git a/lib/objectModification.ts b/lib/objectModification.ts new file mode 100644 index 0000000..1e58bab --- /dev/null +++ b/lib/objectModification.ts @@ -0,0 +1,97 @@ +/** + * Object modification and deletion utilities + * Only the author (pubkey) who published the original note can modify or delete it + * + * Access rules: + * - Modification: Only the author (event.pubkey === userPubkey) can modify + * - Deletion: Only the author (event.pubkey === userPubkey) can delete + * - Read access: All users can read previews, paid content requires payment verification + */ + +import type { Event } from 'nostr-tools' +import { extractTagsFromEvent } from './nostrTagSystem' +import { canModifyObject, getNextVersion } from './versionManager' + +/** + * Check if user can modify an object + * Only the author (pubkey) who published the original note can modify it + */ +export function canUserModifyObject(event: Event, userPubkey: string): boolean { + return canModifyObject(event, userPubkey) +} + +/** + * Build an update event (new version) + * Increments version and keeps the same hash ID + */ +export async function buildUpdateEvent( + originalEvent: Event, + updatedData: Record, + userPubkey: string +): Promise { + // Check if user can modify + if (!canUserModifyObject(originalEvent, userPubkey)) { + throw new Error('Only the author can modify this object') + } + + const tags = extractTagsFromEvent(originalEvent) + const nextVersion = getNextVersion([originalEvent]) + + // Build new event with incremented version + // The hash ID stays the same, only version changes + const newTags = originalEvent.tags.map((tag) => { + if (tag[0] === 'version') { + return ['version', nextVersion.toString()] + } + return tag + }) + + // Remove hidden tag if present (object is being updated, not deleted) + const filteredTags = newTags.filter((tag) => !(tag[0] === 'hidden' && tag[1] === 'true')) + + return { + ...originalEvent, + tags: filteredTags, + created_at: Math.floor(Date.now() / 1000), + // Content and other fields should be updated by the caller + } as Event +} + +/** + * Build a delete event (hide object) + * Sets hidden=true and increments version + */ +export async function buildDeleteEvent( + originalEvent: Event, + userPubkey: string +): Promise { + // Check if user can modify + if (!canUserModifyObject(originalEvent, userPubkey)) { + throw new Error('Only the author can delete this object') + } + + const tags = extractTagsFromEvent(originalEvent) + const nextVersion = getNextVersion([originalEvent]) + + // Build new event with hidden=true and incremented version + const newTags = originalEvent.tags.map((tag) => { + if (tag[0] === 'version') { + return ['version', nextVersion.toString()] + } + if (tag[0] === 'hidden') { + return ['hidden', 'true'] + } + return tag + }) + + // Add hidden tag if not present + if (!newTags.some((tag) => tag[0] === 'hidden')) { + newTags.push(['hidden', 'true']) + } + + return { + ...originalEvent, + tags: newTags, + created_at: Math.floor(Date.now() / 1000), + } as Event +} diff --git a/lib/platformConfig.ts b/lib/platformConfig.ts index 195447b..1067c1c 100644 --- a/lib/platformConfig.ts +++ b/lib/platformConfig.ts @@ -1,29 +1,3 @@ export const PLATFORM_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu' export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y' - -import { getPlatformLightningAddress as getAddress, getPlatformLightningAddressSync as getAddressSync } from './config' - -/** - * Platform Lightning address for receiving commissions - * This should be configured with the platform's Lightning node - * Format: user@domain.com or LNURL - * - * @deprecated Use getPlatformLightningAddress() or getPlatformLightningAddressSync() instead - */ -export const PLATFORM_LIGHTNING_ADDRESS = '' - -/** - * Get platform Lightning address (async) - * Uses IndexedDB if available, otherwise returns default - */ -export async function getPlatformLightningAddress(): Promise { - return getAddress() -} - -/** - * Get platform Lightning address (sync) - * Returns default if IndexedDB is not ready - */ -export function getPlatformLightningAddressSync(): string { - return getAddressSync() -} +export const PLATFORM_SERVICE = 'zapwall.fr' diff --git a/lib/presentationParsing.ts b/lib/presentationParsing.ts index 702395f..239f89d 100644 --- a/lib/presentationParsing.ts +++ b/lib/presentationParsing.ts @@ -2,13 +2,45 @@ import type { Article } from '@/types/nostr' /** * Extract presentation data from article content - * Content format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}" + * Supports two formats: + * 1. Old format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}" + * 2. New format: "Nouveau profil publié sur zapwall.fr\n\n\nPrésentation personnelle : \nDescription de votre contenu : \nAdresse Bitcoin mainnet (pour le sponsoring) : \n\n---\n\n[Metadata JSON]\n" + * The profile JSON is stored in the [Metadata JSON] section of the content, not in tags */ export function extractPresentationData(presentation: Article): { presentation: string contentDescription: string } { const content = presentation.content + + // Try new format first + const newFormatMatch = content.match(/Présentation personnelle : (.+?)(?:\nDescription de votre contenu :|$)/s) + const descriptionMatch = content.match(/Description de votre contenu : (.+?)(?:\nAdresse Bitcoin mainnet|$)/s) + + if (newFormatMatch && descriptionMatch) { + return { + presentation: newFormatMatch[1].trim(), + contentDescription: descriptionMatch[1].trim(), + } + } + + // Try to extract from JSON metadata section + const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s) + if (jsonMatch) { + try { + const profileJson = JSON.parse(jsonMatch[1].trim()) + if (profileJson.presentation && profileJson.contentDescription) { + return { + presentation: profileJson.presentation, + contentDescription: profileJson.contentDescription, + } + } + } catch (e) { + // JSON parsing failed, continue with old format + } + } + + // Fallback to old format const separator = '\n\n---\n\nDescription du contenu :\n' const separatorIndex = content.indexOf(separator) diff --git a/lib/reviews.ts b/lib/reviews.ts index fd5f7ea..df64e0a 100644 --- a/lib/reviews.ts +++ b/lib/reviews.ts @@ -4,11 +4,13 @@ import type { Review } from '@/types/nostr' import { parseReviewFromEvent } from './nostrEventParsing' import { buildTagFilter } from './nostrTagSystem' import { getPrimaryRelaySync } from './config' +import { PLATFORM_SERVICE } from './platformConfig' function buildReviewFilters(articleId: string) { const tagFilter = buildTagFilter({ type: 'quote', articleId, + service: PLATFORM_SERVICE, }) const filterObj: { diff --git a/lib/seriesQueries.ts b/lib/seriesQueries.ts index 6ac352a..ebf9e53 100644 --- a/lib/seriesQueries.ts +++ b/lib/seriesQueries.ts @@ -4,11 +4,13 @@ import type { Series } from '@/types/nostr' import { parseSeriesFromEvent } from './nostrEventParsing' import { buildTagFilter } from './nostrTagSystem' import { getPrimaryRelaySync } from './config' +import { PLATFORM_SERVICE } from './platformConfig' function buildSeriesFilters(authorPubkey: string) { const tagFilter = buildTagFilter({ type: 'series', authorPubkey, + service: PLATFORM_SERVICE, }) return [ @@ -62,6 +64,7 @@ function buildSeriesByIdFilters(seriesId: string) { ids: [seriesId], ...buildTagFilter({ type: 'series', + service: PLATFORM_SERVICE, }), }, ] diff --git a/lib/sponsoring.ts b/lib/sponsoring.ts index efb4fe8..cbaf890 100644 --- a/lib/sponsoring.ts +++ b/lib/sponsoring.ts @@ -2,6 +2,7 @@ import { nostrService } from './nostr' import type { Article } from '@/types/nostr' import { getPrimaryRelaySync } from './config' import { buildTagFilter, extractTagsFromEvent } from './nostrTagSystem' +import { PLATFORM_SERVICE } from './platformConfig' function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey: string): Promise { const filters = [ @@ -9,6 +10,7 @@ function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey: ...buildTagFilter({ type: 'author', authorPubkey: pubkey, + service: PLATFORM_SERVICE, }), limit: 1, }, diff --git a/lib/urlGenerator.ts b/lib/urlGenerator.ts new file mode 100644 index 0000000..89c0006 --- /dev/null +++ b/lib/urlGenerator.ts @@ -0,0 +1,56 @@ +/** + * Generate URLs for objects in the format: + * https://zapwall.fr//__ + * + * - object_type_name: author, series, publication, review + * - id_hash: SHA-256 hash of the object + * - index: number of objects of the same type with the same hash (0 by default) + * - version: version number (0 by default) + */ + +export type ObjectTypeName = 'author' | 'series' | 'publication' | 'review' + +/** + * Generate URL for an object + */ +export function generateObjectUrl( + objectType: ObjectTypeName, + idHash: string, + index: number = 0, + version: number = 0 +): string { + return `https://zapwall.fr/${objectType}/${idHash}_${index}_${version}` +} + +/** + * Parse URL to extract object information + */ +export function parseObjectUrl(url: string): { + objectType: ObjectTypeName | null + idHash: string | null + index: number | null + version: number | null +} { + const match = url.match(/https?:\/\/zapwall\.fr\/(author|series|publication|review)\/([a-f0-9]+)_(\d+)_(\d+)/i) + if (!match) { + return { objectType: null, idHash: null, index: null, version: null } + } + + return { + objectType: match[1] as ObjectTypeName, + idHash: match[2], + index: parseInt(match[3], 10), + version: parseInt(match[4], 10), + } +} + +/** + * Get the object type name from tag type + */ +export function getObjectTypeName(tagType: 'author' | 'series' | 'publication' | 'quote'): ObjectTypeName { + // Map 'quote' to 'review' for URLs + if (tagType === 'quote') { + return 'review' + } + return tagType +} diff --git a/lib/versionManager.ts b/lib/versionManager.ts new file mode 100644 index 0000000..17de34d --- /dev/null +++ b/lib/versionManager.ts @@ -0,0 +1,139 @@ +/** + * Version management for objects + * Handles versioning and hiding of objects + * Only the author (pubkey) who published the original note can modify or delete it + */ + +import type { Event } from 'nostr-tools' +import { extractTagsFromEvent } from './nostrTagSystem' + +export interface VersionedObject { + event: Event + version: number + hidden: boolean + pubkey: string + id: string +} + +/** + * Filter events to get only the latest version that is not hidden + * Groups events by ID and returns the one with the highest version that is not hidden + */ +export function getLatestVersion(events: Event[]): Event | null { + if (events.length === 0) { + return null + } + + // Group by ID and find the latest non-hidden version + const byId = new Map() + + for (const event of events) { + const tags = extractTagsFromEvent(event) + if (!tags.id) { + continue + } + + if (!byId.has(tags.id)) { + byId.set(tags.id, []) + } + + byId.get(tags.id)!.push({ + event, + version: tags.version, + hidden: tags.hidden, + pubkey: event.pubkey, + id: tags.id, + }) + } + + // For each ID, find the latest non-hidden version + const latestVersions: VersionedObject[] = [] + + for (const objects of byId.values()) { + // Filter out hidden objects + const visible = objects.filter((obj) => !obj.hidden) + + if (visible.length === 0) { + // All versions are hidden, skip this object + continue + } + + // Sort by version (descending) and take the first (latest) + visible.sort((a, b) => b.version - a.version) + latestVersions.push(visible[0]) + } + + // If we have multiple IDs, we need to return the one with the highest version + // But typically we expect one ID per query, so return the first + if (latestVersions.length === 0) { + return null + } + + // Sort by version and return the latest + latestVersions.sort((a, b) => b.version - a.version) + return latestVersions[0].event +} + +/** + * Get all versions of an object (for version history) + */ +export function getAllVersions(events: Event[]): VersionedObject[] { + const versions: VersionedObject[] = [] + + for (const event of events) { + const tags = extractTagsFromEvent(event) + if (!tags.id) { + continue + } + + versions.push({ + event, + version: tags.version, + hidden: tags.hidden, + pubkey: event.pubkey, + id: tags.id, + }) + } + + // Sort by version (descending) + versions.sort((a, b) => b.version - a.version) + return versions +} + +/** + * Check if a user can modify or delete an object + * Only the original author (pubkey) can modify/delete + */ +export function canModifyObject(event: Event, userPubkey: string): boolean { + return event.pubkey === userPubkey +} + +/** + * Get the next version number for an object + * Finds the highest version and increments it + */ +export function getNextVersion(events: Event[]): number { + if (events.length === 0) { + return 0 + } + + let maxVersion = -1 + for (const event of events) { + const tags = extractTagsFromEvent(event) + if (tags.version > maxVersion) { + maxVersion = tags.version + } + } + + return maxVersion + 1 +} + +/** + * Count objects with the same hash ID (for index calculation) + */ +export function countObjectsWithSameHash(events: Event[], hashId: string): number { + return events.filter((event) => { + const tags = extractTagsFromEvent(event) + return tags.id === hashId + }).length +} diff --git a/locales/en.txt b/locales/en.txt index 1d5b724..04d1a72 100644 --- a/locales/en.txt +++ b/locales/en.txt @@ -110,6 +110,11 @@ common.loading=Loading... common.loading.articles=Loading articles... common.loading.authors=Loading authors... common.error=Error +common.error.noContent=No content found +common.empty.articles=No articles found. Check back later! +common.empty.articles.filtered=No articles match your search or filters. +common.empty.authors=No authors found. Check back later! +common.empty.authors.filtered=No authors match your search or filters. common.back=Back common.open=Open diff --git a/locales/fr.txt b/locales/fr.txt index ee63983..fe44680 100644 --- a/locales/fr.txt +++ b/locales/fr.txt @@ -110,6 +110,11 @@ common.loading=Chargement... common.loading.articles=Chargement des articles... common.loading.authors=Chargement des auteurs... common.error=Erreur +common.error.noContent=Aucun contenu trouvé +common.empty.articles=Aucun article trouvé. Revenez plus tard ! +common.empty.articles.filtered=Aucun article ne correspond à votre recherche ou à vos filtres. +common.empty.authors=Aucun auteur trouvé. Revenez plus tard ! +common.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres. common.back=Retour common.open=Ouvrir diff --git a/pages/api/nip95-upload.ts b/pages/api/nip95-upload.ts index fb3aa8a..8daee32 100644 --- a/pages/api/nip95-upload.ts +++ b/pages/api/nip95-upload.ts @@ -72,15 +72,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Recreate FormData for each request (needed for redirects) const requestFormData = new FormData() - const fileStream = fs.createReadStream(fileField.filepath) + const fileStream = fs.createReadStream(fileField.filepath) // Use 'file' as field name (standard for NIP-95, but some endpoints may use different names) // Note: nostrimg.com might expect a different field name - if issues persist, try 'image' or 'upload' const fieldName = 'file' requestFormData.append(fieldName, fileStream, { - filename: fileField.originalFilename || fileField.newFilename || 'upload', - contentType: fileField.mimetype || 'application/octet-stream', - }) + filename: fileField.originalFilename || fileField.newFilename || 'upload', + contentType: fileField.mimetype || 'application/octet-stream', + }) const isHttps = url.protocol === 'https:' const clientModule = isHttps ? https : http @@ -99,12 +99,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (url.hostname.includes('nostrimg.com')) { console.log('NIP-95 proxy request to nostrimg.com:', { url: url.toString(), - method: 'POST', + method: 'POST', fieldName, filename: fileField.originalFilename || fileField.newFilename || 'upload', contentType: fileField.mimetype || 'application/octet-stream', fileSize: fileField.size, - headers: { + headers: { 'Content-Type': headers['content-type'], 'Accept': headers['Accept'], 'User-Agent': headers['User-Agent'], @@ -178,7 +178,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) statusCode: statusCode, statusMessage: proxyResponse.statusMessage || 'Internal Server Error', body: body, - }) + }) }) proxyResponse.on('error', (error) => { reject(error) diff --git a/public/locales/en.txt b/public/locales/en.txt index 1d5b724..04d1a72 100644 --- a/public/locales/en.txt +++ b/public/locales/en.txt @@ -110,6 +110,11 @@ common.loading=Loading... common.loading.articles=Loading articles... common.loading.authors=Loading authors... common.error=Error +common.error.noContent=No content found +common.empty.articles=No articles found. Check back later! +common.empty.articles.filtered=No articles match your search or filters. +common.empty.authors=No authors found. Check back later! +common.empty.authors.filtered=No authors match your search or filters. common.back=Back common.open=Open diff --git a/public/locales/fr.txt b/public/locales/fr.txt index ee63983..fe44680 100644 --- a/public/locales/fr.txt +++ b/public/locales/fr.txt @@ -110,6 +110,11 @@ common.loading=Chargement... common.loading.articles=Chargement des articles... common.loading.authors=Chargement des auteurs... common.error=Erreur +common.error.noContent=Aucun contenu trouvé +common.empty.articles=Aucun article trouvé. Revenez plus tard ! +common.empty.articles.filtered=Aucun article ne correspond à votre recherche ou à vos filtres. +common.empty.authors=Aucun auteur trouvé. Revenez plus tard ! +common.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres. common.back=Retour common.open=Ouvrir