Remove connection button and replace with direct account creation/import options

This commit is contained in:
Nicolas Cantu 2025-12-28 20:35:50 +01:00
parent aa21dc3ad6
commit 46d5f03fbe
123 changed files with 8863 additions and 3022 deletions

View File

@ -264,3 +264,119 @@ Ces consignes constituent un cadre de production strict. Elles imposent une anal
* ARIA
* clavier
* contraste
## Open Source
Le projet est open source et hébergé sur Gitea auto-hébergé. Toutes les contributions doivent respecter les principes open source suivants.
### Principes open source
* **Transparence** : Tous les changements doivent être traçables, documentés et accessibles publiquement
* **Collaboration** : Le code doit être conçu pour faciliter les contributions externes
* **Documentation** : Toute fonctionnalité ou modification doit être documentée pour les contributeurs externes
* **Standards ouverts** : Utiliser des standards ouverts et éviter les dépendances propriétaires non documentées
* **Licence MIT** : Le code doit respecter la licence MIT et être compatible avec cette licence
### Contribution et workflow open source
* **Commits systématiques** : Tous les changements doivent être commités avec des messages structurés
* **Format de commit** : Les commits doivent suivre le format défini dans CONTRIBUTING.md avec sections Motivations, Root causes, Correctifs, Evolutions, Pages affectées
* **Pull Requests** : Tous les changements doivent passer par des Pull Requests sur Gitea, même pour les modifications internes
* **Documentation des changements** : Toute modification doit être documentée dans `fixKnowledge/` (pour les corrections) ou `features/` (pour les évolutions)
* **Traçabilité** : Chaque modification doit être liée à une issue ou une PR sur Gitea si applicable
### Qualité pour contributions externes
* **Code lisible** : Le code doit être compréhensible par des contributeurs externes sans connaissance préalable du contexte interne
* **Commentaires explicatifs** : Les choix de conception non évidents doivent être commentés
* **Documentation à jour** : La documentation doit toujours refléter l'état actuel du code
* **Exemples et guides** : Fournir des exemples d'utilisation et des guides pour les nouvelles fonctionnalités
* **Tests et validation** : Le code doit être validé et testable par des contributeurs externes
### Gestion des contributions
* **Respect du Code of Conduct** : Tous les contributeurs doivent respecter le CODE_OF_CONDUCT.md
* **Review process** : Toutes les contributions doivent être revues avant merge
* **Feedback constructif** : Fournir un feedback constructif et respectueux sur les contributions
* **Attribution** : Créditer les contributeurs dans les commits et la documentation
### Sécurité open source
* **Reporting de vulnérabilités** : Suivre le processus défini dans SECURITY.md
* **Pas de secrets** : Aucun secret, clé API ou credential ne doit être commité
* **Dépendances sécurisées** : Vérifier la sécurité des dépendances avant ajout
* **Audit de sécurité** : Les changements de sécurité doivent être documentés et audités
### Repository et infrastructure
* **Repository Gitea** : https://git.4nkweb.com/4nk/story-research-zapwall
* **Templates** : Utiliser les templates d'issues et de PR dans `.gitea/`
* **Labels et organisation** : Utiliser les labels appropriés pour organiser les issues et PRs
* **Branches** : Respecter la convention de nommage des branches (feature/, fix/, etc.)
## Commits systématiques
**Règle absolue** : Tous les changements doivent être commités immédiatement après leur réalisation.
### Obligation de commit
* **Pas de modifications non commitées** : Aucune modification ne doit rester non commitée
* **Commits atomiques** : Chaque commit doit représenter une modification logique et complète
* **Commits fréquents** : Commiter régulièrement, pas seulement à la fin d'une session
* **Pas de stash prolongé** : Éviter de laisser des modifications en stash sans les commiter
### Format de commit obligatoire
Tous les commits doivent suivre ce format structuré :
```
Titre court et descriptif
**Motivations:**
- Raison de la modification
**Root causes:**
- Cause racine du problème (si applicable)
**Correctifs:**
- Ce qui a été corrigé
**Evolutions:**
- Nouvelles fonctionnalités ou améliorations
**Pages affectées:**
- Liste des fichiers/modules modifiés
```
### Processus de commit
1. **Avant chaque commit** :
- Vérifier que le code compile (`npm run type-check`)
- Vérifier le linting (`npm run lint`)
- Vérifier que les modifications sont complètes et fonctionnelles
2. **Création du commit** :
- Utiliser `git add` pour les fichiers modifiés
- Créer un commit avec le format structuré
- Ne pas utiliser `--no-verify` sauf cas exceptionnel documenté
3. **Après le commit** :
- Vérifier que le commit a bien été créé (`git log`)
- Documenter dans `fixKnowledge/` ou `features/` si nécessaire
### Exceptions
Les seules exceptions à la règle de commit immédiat sont :
* **Modifications en cours de test** : Si une modification nécessite des tests manuels avant commit, elle doit être commitée dès que les tests sont validés
* **Refactoring en plusieurs étapes** : Les refactorings complexes peuvent être commités par étapes logiques
* **Documentation en cours** : La documentation peut être commitée séparément du code si elle est volumineuse
Dans tous les cas, aucun changement ne doit rester non commité plus de quelques heures.
### Intégration avec le workflow open source
* **Commits avant PR** : Tous les commits doivent être faits avant la création d'une Pull Request
* **Commits dans les PRs** : Les commits dans une PR doivent être organisés et logiques
* **Squash si nécessaire** : Les commits peuvent être squashés dans une PR si cela améliore la lisibilité, mais chaque commit individuel doit rester valide
* **Historique propre** : Maintenir un historique Git propre et lisible pour les contributeurs externes

View File

@ -0,0 +1,52 @@
---
name: Bug Report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
---
## Bug Description
A clear and concise description of what the bug is.
## Steps to Reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected Behavior
A clear and concise description of what you expected to happen.
## Actual Behavior
A clear and concise description of what actually happened.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Environment
- **Browser**: [e.g. Chrome 120, Firefox 121]
- **OS**: [e.g. Windows 11, macOS 14, Linux]
- **Alby Extension**: [e.g. Version 5.2.0]
- **Node Version**: [if relevant]
## Console Errors
If applicable, paste any console errors here:
```
Paste console errors here
```
## Additional Context
Add any other context about the problem here.
## Possible Solution
If you have ideas on how to fix this, please describe them here.

View File

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security Vulnerability
url: https://git.4nkweb.com/4nk/story-research-zapwall/issues/new?template=security
about: Please report security vulnerabilities privately (use [SECURITY] prefix)
- name: Documentation
url: https://git.4nkweb.com/4nk/story-research-zapwall/src/branch/main/docs
about: Check our documentation for answers

View File

@ -0,0 +1,46 @@
---
name: Feature Request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
---
## Feature Description
A clear and concise description of the feature you'd like to see.
## Problem Statement
Is your feature request related to a problem? Please describe.
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Proposed Solution
Describe the solution you'd like to see implemented.
## Alternatives Considered
Describe any alternative solutions or features you've considered.
## Use Cases
Describe specific use cases for this feature:
1. Use case 1
2. Use case 2
3. Use case 3
## Technical Considerations
If applicable, describe any technical considerations:
- Performance implications
- Security considerations
- Integration with existing features
- Breaking changes
## Additional Context
Add any other context, mockups, or examples about the feature request here.
## Implementation Notes
If you have ideas on how to implement this, please share them here.

View File

@ -0,0 +1,25 @@
---
name: Question
about: Ask a question about the project
title: '[QUESTION] '
labels: question
---
## Question
Your question here.
## Context
Provide context for your question:
- What are you trying to accomplish?
- What have you tried so far?
- What documentation have you consulted?
## Relevant Documentation
- [Link to relevant docs](url)
## Additional Information
Any other information that might help answer your question.

View File

@ -0,0 +1,56 @@
## Description
Please include a summary of the change and which issue is fixed. Include relevant motivation and context.
Fixes # (issue)
## Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Code refactoring
## Changes Made
**Motivations:**
-
**Root causes:**
-
**Correctifs:**
-
**Evolutions:**
-
**Pages affectées:**
-
## Testing
- [ ] I have tested my changes locally
- [ ] I have run `npm run lint` and it passes
- [ ] I have run `npm run type-check` and it passes
- [ ] I have tested the changes in the browser with Alby extension
- [ ] I have verified accessibility (keyboard navigation, ARIA, contrast)
## Checklist
- [ ] My code follows the project's coding guidelines
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings or errors
- [ ] I have added tests if applicable (only if explicitly requested)
- [ ] Any dependent changes have been merged and published
## Screenshots
If applicable, add screenshots to help explain your changes.
## Additional Notes
Add any additional notes about the PR here.

119
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,119 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[INSERT CONTACT METHOD]. All complaints will be reviewed and investigated
promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

View File

@ -1,31 +1,240 @@
# Contributing to zapwall4science
# Contributing to zapwall4Science
## Principles
- No fallbacks or silent failures.
- No analytics; no tests added unless explicitly requested.
- Respect lint, type-check, accessibility and exactOptionalPropertyTypes.
- No `ts-ignore`, no untyped `any`, no console logs if a logger exists.
Thank you for your interest in contributing to zapwall4Science! This document provides guidelines and instructions for contributing to the project.
## Setup
- Node 18+, npm
- `npm install`
- `npm run lint`
- `npm run type-check`
## Table of Contents
## Coding guidelines
- Split large components/functions to stay within lint limits (max-lines, max-lines-per-function).
- Prefer typed helpers/hooks; avoid duplication.
- Errors must surface with clear messages; do not swallow exceptions.
- Storage: IndexedDB encrypted (AES-GCM) via `lib/storage/cryptoHelpers.ts`; use provided helpers.
- Nostr: use `lib/articleMutations.ts` and `lib/nostr*.ts` helpers; no direct fallbacks.
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Coding Guidelines](#coding-guidelines)
- [Workflow](#workflow)
- [Commit Guidelines](#commit-guidelines)
- [Pull Request Process](#pull-request-process)
- [Documentation](#documentation)
- [What Not to Do](#what-not-to-do)
## Code of Conduct
By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). We are committed to providing a welcoming and inclusive environment for all contributors.
## Getting Started
1. **Fork the repository** on Gitea
2. **Clone your fork** locally:
```bash
git clone https://git.4nkweb.com/your-username/story-research-zapwall.git
cd story-research-zapwall
```
3. **Add the upstream remote**:
```bash
git remote add upstream https://git.4nkweb.com/4nk/story-research-zapwall.git
```
## Development Setup
### Prerequisites
- **Node.js**: 18 or higher
- **npm**: Latest version
- **Alby browser extension**: For testing Nostr authentication and Lightning payments
### Installation
1. Install dependencies:
```bash
npm install
```
2. Run linting:
```bash
npm run lint
```
3. Run type checking:
```bash
npm run type-check
```
4. Start the development server:
```bash
npm run dev
```
5. Open [http://localhost:3000](http://localhost:3000) in your browser
## Coding Guidelines
### Core Principles
- **No fallbacks or silent failures**: All errors must be explicitly handled and logged
- **No analytics**: Analytics are not allowed in this project
- **No tests unless explicitly requested**: Do not add ad-hoc tests
- **Strict TypeScript**: Respect lint, type-check, accessibility, and `exactOptionalPropertyTypes`
- **No shortcuts**: No `ts-ignore`, no untyped `any`, no `console.log` if a logger exists
### Code Quality
- **Split large components/functions**: Stay within lint limits (max-lines, max-lines-per-function)
- **Prefer typed helpers/hooks**: Avoid duplication, reuse existing utilities
- **Error handling**: Errors must surface with clear messages; do not swallow exceptions
- **Storage**: Use IndexedDB encrypted (AES-GCM) via `lib/storage/cryptoHelpers.ts`
- **Nostr**: Use `lib/articleMutations.ts` and `lib/nostr*.ts` helpers; no direct fallbacks
### TypeScript Standards
- Full type coverage: No `any` types without explicit justification
- No type assertions: Avoid `as` casts unless absolutely necessary
- Strict mode: All TypeScript strict checks must pass
- No `@ts-ignore` or `@ts-nocheck`: Fix type issues properly
### Accessibility
- **ARIA**: Proper ARIA labels and roles
- **Keyboard navigation**: All interactive elements must be keyboard accessible
- **Contrast**: Meet WCAG contrast requirements
- **No regressions**: Maintain or improve accessibility with each change
## Workflow
- Branch from main; keep commits focused.
- Run lint + type-check before PR.
- Document fixes in `fixKnowledge/` and features in `features/`.
## Accessibility
- Respect ARIA, keyboard, contrast requirements; no regressions.
### Creating a Branch
## What not to do
- No analytics, no ad-hoc tests, no environment overrides, no silent retry/fallback.
1. **Update your fork**:
```bash
git checkout main
git pull upstream main
```
2. **Create a feature branch**:
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix
```
### Making Changes
1. Make your changes following the [coding guidelines](#coding-guidelines)
2. Test your changes locally
3. Run lint and type-check:
```bash
npm run lint
npm run type-check
```
### Before Submitting
- [ ] Code follows the project's coding guidelines
- [ ] Lint passes (`npm run lint`)
- [ ] Type-check passes (`npm run type-check`)
- [ ] No `console.log` statements (use logger if available)
- [ ] Errors are properly handled and logged
- [ ] Accessibility requirements are met
- [ ] Documentation is updated if needed
## Commit Guidelines
### Commit Message Format
Commits should be exhaustive and synthetic with the following structure:
```
**Motivations:**
- Reason for the change
**Root causes:**
- Underlying issue (if applicable)
**Correctifs:**
- What was fixed
**Evolutions:**
- New features or improvements
**Pages affectées:**
- Files or components modified
```
### Example
```
Fix payment modal not closing after successful payment
**Motivations:**
- Users reported payment modal staying open after successful payment
**Root causes:**
- Missing state update after payment confirmation
**Correctifs:**
- Added state reset in payment success handler
- Added cleanup in useEffect hook
**Evolutions:**
- Improved user experience with automatic modal closure
**Pages affectées:**
- components/PaymentModal.tsx
- hooks/useArticlePayment.ts
```
## Pull Request Process
1. **Update your branch**:
```bash
git checkout main
git pull upstream main
git checkout your-branch-name
git rebase main
```
2. **Push your changes**:
```bash
git push origin your-branch-name
```
3. **Create a Pull Request** on Gitea:
- Use a clear, descriptive title
- Fill out the PR template completely
- Reference any related issues
- Add screenshots if UI changes are involved
4. **Wait for review**: Maintainers will review your PR and provide feedback
5. **Address feedback**: Make requested changes and push updates to your branch
6. **Documentation**:
- Document fixes in `fixKnowledge/` directory
- Document features in `features/` directory
## Documentation
### When to Document
- **Fixes**: Document in `fixKnowledge/` with problem, impacts, root cause, corrections, modifications, deployment, and analysis procedures
- **Features**: Document in `features/` with objective, impacts, modifications, deployment, and analysis procedures
### Documentation Format
Follow the existing documentation structure:
- Clear sections with headers
- Code examples where relevant
- Links to related documentation
- Author attribution (Équipe 4NK)
## What Not to Do
- ❌ **No analytics**: Do not add analytics tracking
- ❌ **No ad-hoc tests**: Do not add tests unless explicitly requested
- ❌ **No environment overrides**: Do not override environment variables
- ❌ **No silent retry/fallback**: All failures must be explicit
- ❌ **No shortcuts**: No `ts-ignore`, no `any`, no `console.log`
- ❌ **No breaking changes**: Maintain backward compatibility unless explicitly required
## Getting Help
- **Documentation**: Check the [docs/](docs/) directory
- **Issues**: Search existing issues or create a new one on [Gitea](https://git.4nkweb.com/4nk/story-research-zapwall/issues)
- **Repository**: [https://git.4nkweb.com/4nk/story-research-zapwall](https://git.4nkweb.com/4nk/story-research-zapwall)
Thank you for contributing to zapwall4Science! 🚀

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 zapwall4Science Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

116
README.md
View File

@ -1,7 +1,24 @@
# zapwall4Science
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue.svg)](https://www.typescriptlang.org/)
[![Next.js](https://img.shields.io/badge/Next.js-14-black.svg)](https://nextjs.org/)
Plateforme de publication d'articles scientifiques et de science-fiction avec système de sponsoring, commissions et rémunération des avis. Les lecteurs peuvent lire les aperçus gratuitement et débloquer le contenu complet en payant avec Lightning Network.
**Repository**: [https://git.4nkweb.com/4nk/story-research-zapwall](https://git.4nkweb.com/4nk/story-research-zapwall)
## Table of Contents
- [Features](#features)
- [Getting Started](#getting-started)
- [Configuration](#configuration)
- [Lightning Wallet Setup](#lightning-wallet-setup)
- [Project Structure](#project-structure)
- [Contributing](#contributing)
- [Documentation](#documentation)
- [License](#license)
## Features
- **Nostr Authentication**: Authenticate using Alby browser extension (NIP-07)
@ -25,23 +42,24 @@ npm run dev
3. Open [http://localhost:3000](http://localhost:3000) in your browser
## Environment Variables
## Configuration
- `NEXT_PUBLIC_NOSTR_RELAY_URL`: Nostr relay URL (default: wss://relay.damus.io)
- `NEXT_PUBLIC_NIP95_UPLOAD_URL`: NIP-95 media upload endpoint URL (required for image/video uploads)
The application stores all configuration in IndexedDB (browser storage) with hardcoded defaults. No environment variables are required.
### NIP-95 Upload Service
### Default Configuration
The application requires a NIP-95 compatible upload service for media uploads (images and videos). You can use services like:
- [nostr.build](https://nostr.build/) - Public NIP-95 service
- [void.cat](https://void.cat/) - Another public NIP-95 service
- Or host your own NIP-95 compatible service
- **Nostr Relay**: `wss://relay.damus.io` (default)
- **NIP-95 Upload API**: `https://nostr.build/api/v2/upload` (default)
- **Platform Lightning Address**: Empty by default
Example `.env.local`:
```
NEXT_PUBLIC_NOSTR_RELAY_URL=wss://relay.damus.io
NEXT_PUBLIC_NIP95_UPLOAD_URL=https://nostr.build/api/v2/upload
```
### Customizing Configuration
Configuration is stored in IndexedDB and can be customized through the application settings. The application supports:
- Multiple Nostr relays (with priority ordering)
- Multiple NIP-95 upload APIs (with priority ordering)
- Platform Lightning address for commissions
All configuration values are stored locally in the browser and persist across sessions. Default values are hardcoded in the application code.
## Lightning Wallet Setup
@ -58,3 +76,75 @@ Users need to have Alby installed to authenticate and make payments. The applica
- `/lib`: Utilities and Nostr helpers
- `/types`: TypeScript type definitions
- `/hooks`: Custom React hooks
## Déploiement
### Documentation complète
La documentation complète du déploiement est disponible dans le dossier `docs/` :
- **[Documentation complète du déploiement](docs/deployment.md)** : Guide détaillé de déploiement, configuration et maintenance
- **[Référence des scripts](docs/scripts-reference.md)** : Description de tous les scripts disponibles
- **[Guide de référence rapide](docs/quick-reference.md)** : Commandes essentielles
### Déploiement rapide
Le site est déployé sur `zapwall.fr` (serveur : `92.243.27.35`).
**Mise à jour du site** :
```bash
# Méthode recommandée : Script automatique
./update-remote-git.sh
```
**Vérification du statut** :
```bash
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
```
### Informations de déploiement
- **Répertoire** : `/var/www/zapwall.fr`
- **Port application** : `3001`
- **Service systemd** : `zapwall.service`
- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
- **HTTPS** : Configuré avec redirection automatique HTTP → HTTPS
Pour plus de détails, consultez la [documentation complète](docs/deployment.md).
## Contributing
We welcome contributions! Please read our [Contributing Guide](CONTRIBUTING.md) to get started.
### How to Contribute
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes following our [coding guidelines](CONTRIBUTING.md#coding-guidelines)
4. Run lint and type-check (`npm run lint && npm run type-check`)
5. Commit your changes (`git commit -m 'Add amazing feature'`)
6. Push to the branch (`git push origin feature/amazing-feature`)
7. Open a Pull Request
Please ensure your code follows our strict quality standards:
- No fallbacks or silent failures
- Full TypeScript typing (no `any`, no `ts-ignore`)
- Proper error handling and logging
- Accessibility compliance (ARIA, keyboard navigation, contrast)
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
## Documentation
- **[User Guide](docs/user-guide.md)**: Complete user documentation
- **[Technical Documentation](docs/technical.md)**: Architecture and technical details
- **[Deployment Guide](docs/deployment.md)**: Deployment and configuration
- **[FAQ](docs/faq.md)**: Frequently asked questions
- **[Publishing Guide](docs/publishing-guide.md)**: How to publish articles
- **[Payment Guide](docs/payment-guide.md)**: Lightning payment setup
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

143
RESUME-DEPLOIEMENT.md Normal file
View File

@ -0,0 +1,143 @@
# Résumé du déploiement - zapwall.fr
## ✅ Actions effectuées
1. **Déploiement initial** : Application déployée sur `/var/www/zapwall.fr`
2. **Service systemd** : Service `zapwall.service` créé et actif
3. **Configuration nginx** : Reverse proxy configuré dans le conteneur Docker
4. **HTTPS** : Configuration HTTPS avec redirection automatique HTTP → HTTPS
5. **Firewall** : Ports 80 et 443 ouverts
## 📍 État actuel
- **Service** : ✅ Actif sur le port 3001
- **Répertoire** : `/var/www/zapwall.fr` (correct)
- **HTTPS** : ✅ Configuré avec certificats auto-signés
- **Certificats Let's Encrypt** : ⚠️ Non obtenus (bug certbot avec Python 3.11)
## 🔧 Problèmes identifiés et solutions
### 1. Certificats Let's Encrypt
**Problème** : Certbot présente un bug (AttributeError) avec Python 3.11
**Solutions** :
- Utiliser certbot via snap (recommandé) :
```bash
ssh debian@92.243.27.35
sudo snap install certbot --classic
sudo docker stop lecoffre_nginx_test
sudo certbot certonly --standalone -d zapwall.fr --non-interactive --agree-tos --email admin@zapwall.fr
sudo docker start lecoffre_nginx_test
```
### 2. Configuration nginx
**Vérification** : La configuration pointe bien vers le port 3001 (correct)
- `proxy_pass http://172.17.0.1:3001;`
**Note** : La configuration principale nginx.conf contient aussi une config pour `test-lecoffreio.4nkweb.com`, mais elle ne devrait pas interférer car elle utilise un `server_name` différent.
## 📝 Mise à jour du site depuis Git
### Méthode recommandée : Script automatique
Depuis votre machine locale, dans le répertoire du projet :
```bash
# Mise à jour depuis la branche actuelle
./update-from-git.sh
# Ou spécifier une branche
./update-from-git.sh main
```
Le script :
1. Transfère les fichiers depuis votre dépôt Git local
2. Installe les dépendances (`npm ci`)
3. Construit l'application (`npm run build`)
4. Redémarre le service
5. Vérifie que tout fonctionne
### Méthode manuelle
Si vous préférez faire manuellement :
```bash
# 1. Transférer les fichiers
tar --exclude='node_modules' \
--exclude='.next' \
--exclude='.git' \
--exclude='*.tsbuildinfo' \
--exclude='.env*.local' \
--exclude='.cursor' \
-czf - . | ssh debian@92.243.27.35 "cd /var/www/zapwall.fr && tar -xzf -"
# 2. Sur le serveur
ssh debian@92.243.27.35
cd /var/www/zapwall.fr
npm ci
npm run build
sudo systemctl restart zapwall
```
### Initialiser un dépôt Git sur le serveur (optionnel)
Si vous voulez pouvoir faire `git pull` directement sur le serveur :
```bash
ssh debian@92.243.35
cd /var/www/zapwall.fr
git init
git remote add origin https://git.4nkweb.com/4nk/story-research-zapwall.git
git fetch origin
git checkout main # ou la branche souhaitée
```
Ensuite, vous pourrez utiliser :
```bash
ssh debian@92.243.27.35 'cd /var/www/zapwall.fr && git pull && npm ci && npm run build && sudo systemctl restart zapwall'
```
## 🔍 Commandes de vérification
### Vérifier le service
```bash
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
```
### Voir les logs
```bash
ssh debian@92.243.27.35 'sudo journalctl -u zapwall -f'
```
### Vérifier le port
```bash
ssh debian@92.243.27.35 'sudo ss -tuln | grep 3001'
```
### Vérifier la configuration nginx
```bash
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
```
## 📚 Fichiers de documentation créés
- `README-DEPLOYMENT.md` : Guide complet de déploiement et mise à jour
- `update-from-git.sh` : Script de mise à jour automatique
- `fix-nginx-config.sh` : Script de correction de la configuration
- `check-deployment-status.sh` : Script de vérification de l'état
## 🚀 Prochaines étapes recommandées
1. **Obtenir les certificats Let's Encrypt** via snap (voir ci-dessus)
2. **Configurer le renouvellement automatique** des certificats
3. **Tester l'accès** au site en HTTPS
4. **Configurer un dépôt Git sur le serveur** pour faciliter les mises à jour
## ⚠️ Notes importantes
- Le site fonctionne actuellement avec des certificats auto-signés (avertissement navigateur)
- Les modifications de code nécessitent un rebuild et un redémarrage du service
- Le service doit être actif pour que le site soit accessible
- Nginx fait un reverse proxy vers le port 3001 où tourne l'application Next.js

142
SECURITY.md Normal file
View File

@ -0,0 +1,142 @@
# Security Policy
## Supported Versions
We provide security updates for the following versions:
| Version | Supported |
| ------- | ------------------ |
| 1.0.x | :white_check_mark: |
| < 1.0 | :x: |
## Reporting a Vulnerability
We take security vulnerabilities seriously. If you discover a security vulnerability, please follow these steps:
### 1. **Do NOT** create a public issue
Security vulnerabilities should be reported privately to protect users.
### 2. Report via Private Issue
1. Go to the [Issues](https://git.4nkweb.com/4nk/story-research-zapwall/issues) page
2. Create a new issue with the title prefixed with `[SECURITY]`
3. Mark the issue as confidential/private if Gitea supports it
4. Fill out the form with:
- **Title**: Brief description of the vulnerability (prefixed with `[SECURITY]`)
- **Description**: Detailed description of the vulnerability
- **Severity**: Assess the severity (Low, Moderate, High, Critical)
- **Affected versions**: Which versions are affected
- **Steps to reproduce**: How to reproduce the vulnerability
- **Impact**: What could an attacker do with this vulnerability
### 3. Alternative Reporting Methods
If you cannot create a private issue, please contact the maintainers directly through secure channels. Do not disclose the vulnerability publicly until it has been addressed.
### 4. What to Include
Please include the following information in your report:
- **Type of vulnerability** (e.g., XSS, CSRF, authentication bypass, etc.)
- **Location** (file path, component, or endpoint)
- **Steps to reproduce** (detailed steps)
- **Potential impact** (what could an attacker do?)
- **Suggested fix** (if you have one)
- **Proof of concept** (if applicable, but be careful not to include exploits)
### 5. Response Timeline
- **Initial response**: Within 48 hours
- **Status update**: Within 7 days
- **Fix timeline**: Depends on severity
- **Critical**: As soon as possible (typically within 24-48 hours)
- **High**: Within 1 week
- **Medium**: Within 2 weeks
- **Low**: Within 1 month
### 6. Disclosure Policy
- We will acknowledge receipt of your report within 48 hours
- We will keep you informed of our progress
- We will notify you when the vulnerability is fixed
- We will credit you in the security advisory (unless you prefer to remain anonymous)
## Security Best Practices
### For Contributors
- **Never commit secrets**: API keys, private keys, passwords, etc.
- **Use secure dependencies**: Keep dependencies up to date
- **Follow secure coding practices**: Input validation, output encoding, etc.
- **Review security implications**: Consider security impact of changes
### For Users
- **Keep dependencies updated**: Run `npm audit` regularly
- **Use HTTPS**: Always use HTTPS in production
- **Secure your Nostr keys**: Never share your private keys
- **Use secure Lightning wallets**: Only use trusted Lightning wallet extensions
## Known Security Considerations
### Nostr Authentication
- Private keys are managed by the Alby extension (NIP-07)
- We never store or transmit private keys
- All Nostr operations are signed client-side
### Lightning Payments
- Payment requests are generated via WebLN (Alby extension)
- We do not handle private keys or payment secrets
- All payment operations are performed by the user's wallet
### Data Storage
- Private content is encrypted using AES-GCM
- Encryption keys are stored in browser localStorage
- No sensitive data is sent to external servers (except Nostr relays)
### IndexedDB
- Encrypted content is stored in IndexedDB
- Keys are derived from a master key stored in localStorage
- Content expires after 30 days
## Security Updates
Security updates will be announced via:
- Security advisories in issues
- Release notes
- Project documentation
## Responsible Disclosure
We appreciate responsible disclosure. If you follow these guidelines, we will:
- Work with you to understand and resolve the issue quickly
- Credit you in our security advisory (if desired)
- Not take legal action against you
## Security Checklist for Pull Requests
Before submitting a PR, ensure:
- [ ] No hardcoded secrets or credentials
- [ ] Input validation is implemented
- [ ] Output is properly encoded/escaped
- [ ] Error messages don't leak sensitive information
- [ ] Dependencies are up to date (`npm audit`)
- [ ] No `console.log` statements with sensitive data
- [ ] Authentication/authorization is properly implemented
- [ ] HTTPS is used for all external requests
## Additional Resources
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/)
- [Next.js Security](https://nextjs.org/docs/app/building-your-application/configuring/security-headers)
Thank you for helping keep zapwall4Science secure!

View File

@ -11,6 +11,20 @@ interface ArticleCardProps {
onUnlock?: (article: Article) => void
}
function ArticleHeader({ article }: { article: Article }) {
return (
<div className="mb-2 flex items-center justify-between">
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
<Link
href={`/author/${article.pubkey}`}
className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors"
>
{t('publication.viewAuthor')}
</Link>
</div>
)
}
function ArticleMeta({
article,
error,
@ -56,15 +70,7 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps) {
return (
<article className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark hover:border-neon-cyan/50 hover:shadow-glow-cyan transition-all">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
<Link
href={`/author/${article.pubkey}`}
className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors"
>
{t('publication.viewAuthor')}
</Link>
</div>
<ArticleHeader article={article} />
<div className="text-cyber-accent mb-4">
<ArticlePreview
article={article}

View File

@ -1,9 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import Image from 'next/image'
import React from 'react'
import type { Article } from '@/types/nostr'
import { useAuthorsProfiles } from '@/hooks/useAuthorsProfiles'
import { generateMnemonicIcons } from '@/lib/mnemonicIcons'
import { t } from '@/lib/i18n'
import { AuthorFilter } from './AuthorFilter'
export type SortOption = 'newest' | 'oldest'
@ -78,191 +76,6 @@ function FiltersHeader({
)
}
function AuthorFilter({
authors,
value,
onChange,
}: {
authors: string[]
value: string | null
onChange: (value: string | null) => void
}) {
const { profiles, loading } = useAuthorsProfiles(authors)
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
buttonRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
buttonRef.current?.focus()
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
const getDisplayName = (pubkey: string): string => {
const profile = profiles.get(pubkey)
return profile?.name ?? `${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}`
}
const getPicture = (pubkey: string): string | undefined => {
return profiles.get(pubkey)?.picture
}
const getMnemonicIcons = (pubkey: string): string[] => {
return generateMnemonicIcons(pubkey)
}
const selectedAuthor = value ? profiles.get(value) : null
const selectedDisplayName = value ? getDisplayName(value) : t('filters.author')
return (
<div className="relative">
<label htmlFor="author-filter" className="block text-sm font-medium text-cyber-accent mb-1">
{t('filters.author')}
</label>
<div className="relative" ref={dropdownRef}>
<button
id="author-filter"
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent text-left flex items-center gap-2 hover:border-neon-cyan/50 transition-colors"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
{value && selectedAuthor?.picture ? (
<Image
src={selectedAuthor.picture}
alt={selectedDisplayName}
width={24}
height={24}
className="rounded-full object-cover border border-neon-cyan/30"
/>
) : value ? (
<div className="w-6 h-6 rounded-full bg-cyber-light border border-neon-cyan/30 flex items-center justify-center flex-shrink-0">
<span className="text-xs text-neon-cyan font-medium">
{selectedDisplayName.charAt(0).toUpperCase()}
</span>
</div>
) : null}
<span className="flex-1 truncate text-cyber-accent">{selectedDisplayName}</span>
{value && (
<div className="flex items-center gap-1 flex-shrink-0">
{getMnemonicIcons(value).map((icon, idx) => (
<span key={idx} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
{icon}
</span>
))}
</div>
)}
<svg
className={`w-5 h-5 text-neon-cyan transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div
className="absolute z-20 w-full mt-1 bg-cyber-dark border border-neon-cyan/30 rounded-lg shadow-glow-cyan max-h-60 overflow-auto"
role="listbox"
>
<button
type="button"
onClick={() => {
onChange(null)
setIsOpen(false)
}}
className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
value === null ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
}`}
role="option"
aria-selected={value === null}
>
<span className="flex-1 text-cyber-accent">{t('filters.author')}</span>
</button>
{loading ? (
<div className="px-3 py-2 text-sm text-cyber-accent/70">{t('filters.loading')}</div>
) : (
authors.map((pubkey) => {
const displayName = getDisplayName(pubkey)
const picture = getPicture(pubkey)
const mnemonicIcons = getMnemonicIcons(pubkey)
const isSelected = value === pubkey
return (
<button
key={pubkey}
type="button"
onClick={() => {
onChange(pubkey)
setIsOpen(false)
}}
className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
isSelected ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
}`}
role="option"
aria-selected={isSelected}
>
{picture ? (
<Image
src={picture}
alt={displayName}
width={24}
height={24}
className="rounded-full object-cover flex-shrink-0 border border-neon-cyan/30"
/>
) : (
<div className="w-6 h-6 rounded-full bg-cyber-light border border-neon-cyan/30 flex items-center justify-center flex-shrink-0">
<span className="text-xs text-neon-cyan font-medium">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
)}
<span className="flex-1 truncate text-cyber-accent">{displayName}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{mnemonicIcons.map((icon, idx) => (
<span key={idx} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
{icon}
</span>
))}
</div>
</button>
)
})
)}
</div>
)}
</div>
</div>
)
}
function SortFilter({
value,
@ -279,7 +92,7 @@ function SortFilter({
<select
id="sort"
value={value}
onChange={(e) => onChange(e.target.value as SortOption)}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onChange(e.target.value as SortOption)}
className="block w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent hover:border-neon-cyan/50 transition-colors"
>
<option value="newest" className="bg-cyber-dark">{t('filters.sort.newest')}</option>

View File

@ -0,0 +1,90 @@
import React from 'react'
import { t } from '@/lib/i18n'
import { useAuthorFilterProps } from './AuthorFilterHooks'
import { AuthorFilterButtonWrapper } from './AuthorFilterButton'
import { AuthorDropdown } from './AuthorFilterDropdown'
function AuthorFilterLabel() {
return (
<label htmlFor="author-filter" className="block text-sm font-medium text-cyber-accent mb-1">
{t('filters.author')}
</label>
)
}
interface AuthorFilterContentProps {
authors: string[]
value: string | null
isOpen: boolean
loading: boolean
onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void
dropdownRef: React.RefObject<HTMLDivElement>
buttonRef: React.RefObject<HTMLButtonElement>
getDisplayName: (pubkey: string) => string
getPicture: (pubkey: string) => string | undefined
getMnemonicIcons: (pubkey: string) => string[]
selectedAuthor: { name?: string; picture?: string } | null
selectedDisplayName: string
}
function AuthorFilterContent(props: AuthorFilterContentProps) {
return (
<div className="relative" ref={props.dropdownRef}>
<AuthorFilterButtonWrapper
value={props.value}
selectedAuthor={props.selectedAuthor}
selectedDisplayName={props.selectedDisplayName}
getMnemonicIcons={props.getMnemonicIcons}
isOpen={props.isOpen}
setIsOpen={props.setIsOpen}
buttonRef={props.buttonRef}
/>
{props.isOpen && (
<AuthorDropdown
authors={props.authors}
value={props.value}
loading={props.loading}
onChange={props.onChange}
setIsOpen={props.setIsOpen}
getDisplayName={props.getDisplayName}
getPicture={props.getPicture}
getMnemonicIcons={props.getMnemonicIcons}
/>
)}
</div>
)
}
export function AuthorFilter({
authors,
value,
onChange,
}: {
authors: string[]
value: string | null
onChange: (value: string | null) => void
}) {
const props = useAuthorFilterProps(authors, value)
return (
<div className="relative">
<AuthorFilterLabel />
<AuthorFilterContent
authors={props.authors}
value={props.value}
isOpen={props.isOpen}
loading={props.loading}
onChange={onChange}
setIsOpen={props.setIsOpen}
dropdownRef={props.dropdownRef}
buttonRef={props.buttonRef}
getDisplayName={props.getDisplayName}
getPicture={props.getPicture}
getMnemonicIcons={props.getMnemonicIcons}
selectedAuthor={props.selectedAuthor}
selectedDisplayName={props.selectedDisplayName}
/>
</div>
)
}

View File

@ -0,0 +1,121 @@
import React from 'react'
import { AuthorAvatar } from './AuthorFilterDropdown'
export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }) {
return (
<div className="flex items-center gap-1 flex-shrink-0">
{getMnemonicIcons(value).map((icon, idx) => (
<span key={idx} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
{icon}
</span>
))}
</div>
)
}
export function AuthorFilterButtonContent({
value,
selectedAuthor,
selectedDisplayName,
getMnemonicIcons,
}: {
value: string | null
selectedAuthor: { name?: string; picture?: string } | null | undefined
selectedDisplayName: string
getMnemonicIcons: (pubkey: string) => string[]
}) {
return (
<>
{value && (
<AuthorAvatar
{...(selectedAuthor?.picture !== undefined ? { picture: selectedAuthor.picture } : {})}
displayName={selectedDisplayName}
/>
)}
<span className="flex-1 truncate text-cyber-accent">{selectedDisplayName}</span>
{value && <AuthorMnemonicIcons value={value} getMnemonicIcons={getMnemonicIcons} />}
</>
)
}
export function DropdownArrowIcon({ isOpen }: { isOpen: boolean }) {
return (
<svg
className={`w-5 h-5 text-neon-cyan transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)
}
export function AuthorFilterButton({
value,
selectedAuthor,
selectedDisplayName,
getMnemonicIcons,
isOpen,
setIsOpen,
buttonRef,
}: {
value: string | null
selectedAuthor: { name?: string; picture?: string } | null | undefined
selectedDisplayName: string
getMnemonicIcons: (pubkey: string) => string[]
isOpen: boolean
setIsOpen: (open: boolean) => void
buttonRef: React.RefObject<HTMLButtonElement>
}) {
return (
<button
id="author-filter"
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent text-left flex items-center gap-2 hover:border-neon-cyan/50 transition-colors"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<AuthorFilterButtonContent
value={value}
selectedAuthor={selectedAuthor}
selectedDisplayName={selectedDisplayName}
getMnemonicIcons={getMnemonicIcons}
/>
<DropdownArrowIcon isOpen={isOpen} />
</button>
)
}
export function AuthorFilterButtonWrapper({
value,
selectedAuthor,
selectedDisplayName,
getMnemonicIcons,
isOpen,
setIsOpen,
buttonRef,
}: {
value: string | null
selectedAuthor: { name?: string; picture?: string } | null
selectedDisplayName: string
getMnemonicIcons: (pubkey: string) => string[]
isOpen: boolean
setIsOpen: (open: boolean) => void
buttonRef: React.RefObject<HTMLButtonElement>
}) {
return (
<AuthorFilterButton
value={value}
selectedAuthor={selectedAuthor}
selectedDisplayName={selectedDisplayName}
getMnemonicIcons={getMnemonicIcons}
isOpen={isOpen}
setIsOpen={setIsOpen}
buttonRef={buttonRef}
/>
)
}

View File

@ -0,0 +1,224 @@
import React from 'react'
import Image from 'next/image'
import { t } from '@/lib/i18n'
export function AuthorAvatar({ picture, displayName }: { picture?: string; displayName: string }) {
if (picture !== undefined) {
return (
<Image
src={picture}
alt={displayName}
width={24}
height={24}
className="rounded-full object-cover border border-neon-cyan/30"
/>
)
}
return (
<div className="w-6 h-6 rounded-full bg-cyber-light border border-neon-cyan/30 flex items-center justify-center flex-shrink-0">
<span className="text-xs text-neon-cyan font-medium">{displayName.charAt(0).toUpperCase()}</span>
</div>
)
}
export function AuthorOption({
displayName,
picture,
mnemonicIcons,
isSelected,
onSelect,
}: {
displayName: string
picture?: string
mnemonicIcons: string[]
isSelected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
isSelected ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
}`}
role="option"
aria-selected={isSelected}
>
<AuthorAvatar {...(picture !== undefined ? { picture } : {})} displayName={displayName} />
<span className="flex-1 truncate text-cyber-accent">{displayName}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{mnemonicIcons.map((icon, idx) => (
<span key={idx} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
{icon}
</span>
))}
</div>
</button>
)
}
export function AllAuthorsOption({
value,
onChange,
setIsOpen,
}: {
value: string | null
onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void
}) {
return (
<button
type="button"
onClick={() => {
onChange(null)
setIsOpen(false)
}}
className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
value === null ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
}`}
role="option"
aria-selected={value === null}
>
<span className="flex-1 text-cyber-accent">{t('filters.author')}</span>
</button>
)
}
export function createAuthorOptionProps(
pubkey: string,
value: string | null,
getDisplayName: (pubkey: string) => string,
getPicture: (pubkey: string) => string | undefined,
getMnemonicIcons: (pubkey: string) => string[],
onChange: (value: string | null) => void,
setIsOpen: (open: boolean) => void
): {
displayName: string
mnemonicIcons: string[]
isSelected: boolean
onSelect: () => void
picture?: string
} {
const pictureValue = getPicture(pubkey)
const optionProps: {
displayName: string
mnemonicIcons: string[]
isSelected: boolean
onSelect: () => void
picture?: string
} = {
displayName: getDisplayName(pubkey),
mnemonicIcons: getMnemonicIcons(pubkey),
isSelected: value === pubkey,
onSelect: () => {
onChange(pubkey)
setIsOpen(false)
},
}
if (pictureValue !== undefined) {
optionProps.picture = pictureValue
}
return optionProps
}
export function AuthorList({
authors,
value,
getDisplayName,
getPicture,
getMnemonicIcons,
onChange,
setIsOpen,
}: {
authors: string[]
value: string | null
getDisplayName: (pubkey: string) => string
getPicture: (pubkey: string) => string | undefined
getMnemonicIcons: (pubkey: string) => string[]
onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void
}) {
return (
<>
{authors.map((pubkey) => (
<AuthorOption
key={pubkey}
{...createAuthorOptionProps(pubkey, value, getDisplayName, getPicture, getMnemonicIcons, onChange, setIsOpen)}
/>
))}
</>
)
}
export function AuthorDropdownContent({
authors,
value,
loading,
getDisplayName,
getPicture,
getMnemonicIcons,
onChange,
setIsOpen,
}: {
authors: string[]
value: string | null
loading: boolean
getDisplayName: (pubkey: string) => string
getPicture: (pubkey: string) => string | undefined
getMnemonicIcons: (pubkey: string) => string[]
onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void
}) {
return loading ? (
<div className="px-3 py-2 text-sm text-cyber-accent/70">{t('filters.loading')}</div>
) : (
<AuthorList
authors={authors}
value={value}
getDisplayName={getDisplayName}
getPicture={getPicture}
getMnemonicIcons={getMnemonicIcons}
onChange={onChange}
setIsOpen={setIsOpen}
/>
)
}
export function AuthorDropdown({
authors,
value,
loading,
onChange,
setIsOpen,
getDisplayName,
getPicture,
getMnemonicIcons,
}: {
authors: string[]
value: string | null
loading: boolean
onChange: (value: string | null) => void
setIsOpen: (open: boolean) => void
getDisplayName: (pubkey: string) => string
getPicture: (pubkey: string) => string | undefined
getMnemonicIcons: (pubkey: string) => string[]
}) {
return (
<div
className="absolute z-20 w-full mt-1 bg-cyber-dark border border-neon-cyan/30 rounded-lg shadow-glow-cyan max-h-60 overflow-auto"
role="listbox"
>
<AllAuthorsOption value={value} onChange={onChange} setIsOpen={setIsOpen} />
<AuthorDropdownContent
authors={authors}
value={value}
loading={loading}
getDisplayName={getDisplayName}
getPicture={getPicture}
getMnemonicIcons={getMnemonicIcons}
onChange={onChange}
setIsOpen={setIsOpen}
/>
</div>
)
}

View File

@ -0,0 +1,86 @@
import { useState, useEffect, useRef } from 'react'
import { useAuthorsProfiles } from '@/hooks/useAuthorsProfiles'
import { generateMnemonicIcons } from '@/lib/mnemonicIcons'
import { t } from '@/lib/i18n'
export function useAuthorFilterDropdown(isOpen: boolean, setIsOpen: (open: boolean) => void) {
const dropdownRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
buttonRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
buttonRef.current?.focus()
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen, setIsOpen])
return { dropdownRef, buttonRef }
}
export function useAuthorFilterHelpers(profiles: Map<string, { name?: string; picture?: string }>) {
const getDisplayName = (pubkey: string): string => {
const profile = profiles.get(pubkey)
return profile?.name ?? `${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}`
}
const getPicture = (pubkey: string): string | undefined => {
return profiles.get(pubkey)?.picture
}
const getMnemonicIcons = (pubkey: string): string[] => {
return generateMnemonicIcons(pubkey)
}
return { getDisplayName, getPicture, getMnemonicIcons }
}
export function useAuthorFilterState(authors: string[], value: string | null) {
const { profiles, loading } = useAuthorsProfiles(authors)
const [isOpen, setIsOpen] = useState(false)
const { dropdownRef, buttonRef } = useAuthorFilterDropdown(isOpen, setIsOpen)
const { getDisplayName, getPicture, getMnemonicIcons } = useAuthorFilterHelpers(profiles)
const selectedAuthor = value ? (profiles.get(value) ?? null) : null
const selectedDisplayName = value ? getDisplayName(value) : t('filters.author')
return {
profiles,
loading,
isOpen,
setIsOpen,
dropdownRef,
buttonRef,
getDisplayName,
getPicture,
getMnemonicIcons,
selectedAuthor,
selectedDisplayName,
}
}
export function useAuthorFilterProps(authors: string[], value: string | null) {
const state = useAuthorFilterState(authors, value)
return { ...state, authors, value }
}

View File

@ -3,7 +3,7 @@ import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons'
import { ConnectButton } from './ConnectButton'
import { CreateAccountModal } from './CreateAccountModal'
import { ImageUploadField } from './ImageUploadField'
import { PresentationFormHeader } from './PresentationFormHeader'
import { t } from '@/lib/i18n'
@ -193,6 +193,32 @@ function useAuthorPresentationState(pubkey: string | null) {
return { loading, error, success, draft, setDraft, validationError, handleSubmit }
}
function NoAccountView() {
const [showCreateModal, setShowCreateModal] = useState(false)
return (
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-cyber-accent mb-2">
Créez un compte ou importez votre clé secrète pour commencer
</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
Créer un compte ou importer une clé
</button>
{showCreateModal && (
<CreateAccountModal
onSuccess={() => setShowCreateModal(false)}
onClose={() => setShowCreateModal(false)}
/>
)}
</div>
</div>
)
}
function AuthorPresentationFormView({
pubkey,
connected,
@ -205,13 +231,7 @@ function AuthorPresentationFormView({
const state = useAuthorPresentationState(pubkey)
if (!pubkey) {
return (
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
<div className="flex flex-col items-center gap-4">
<ConnectButton />
</div>
</div>
)
return <NoAccountView />
}
if (state.success) {
return <SuccessNotice />

View File

@ -4,6 +4,32 @@ import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { useEffect, useState } from 'react'
import { t } from '@/lib/i18n'
const buttonClassName = 'px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan'
function CreateAuthorPageLink() {
return (
<Link href="/presentation" className={buttonClassName}>
{t('nav.createAuthorPage')}
</Link>
)
}
function PublishLink() {
return (
<Link href="/publish" className={buttonClassName}>
{t('nav.publish')}
</Link>
)
}
function LoadingButton() {
return (
<div className="px-4 py-2 bg-neon-cyan/20 text-neon-cyan rounded-lg text-sm font-medium">
{t('nav.loading')}
</div>
)
}
export function ConditionalPublishButton() {
const { connected, pubkey } = useNostrAuth()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
@ -22,41 +48,16 @@ export function ConditionalPublishButton() {
}, [connected, pubkey, checkPresentationExists])
if (!connected || !pubkey) {
return (
<Link
href="/presentation"
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('nav.createAuthorPage')}
</Link>
)
return <CreateAuthorPageLink />
}
if (hasPresentation === null) {
return (
<div className="px-4 py-2 bg-neon-cyan/20 text-neon-cyan rounded-lg text-sm font-medium">
{t('nav.loading')}
</div>
)
return <LoadingButton />
}
if (!hasPresentation) {
return (
<Link
href="/presentation"
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('nav.createAuthorPage')}
</Link>
)
return <CreateAuthorPageLink />
}
return (
<Link
href="/publish"
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('nav.publish')}
</Link>
)
return <PublishLink />
}

View File

@ -1,50 +1,172 @@
import { useState, useEffect } from 'react'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { ConnectedUserMenu } from './ConnectedUserMenu'
import { CreateAccountModal } from './CreateAccountModal'
import { UnlockAccountModal } from './UnlockAccountModal'
import type { NostrProfile } from '@/types/nostr'
function ConnectForm({ onConnect, loading, error }: {
onConnect: () => void
function ConnectForm({
onCreateAccount,
onUnlock,
loading,
error,
}: {
onCreateAccount: () => void
onUnlock: () => void
loading: boolean
error: string | null
}) {
return (
<div className="flex flex-col gap-2">
<button
onClick={() => {
void onConnect()
}}
onClick={onCreateAccount}
disabled={loading}
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{loading ? 'Connecting...' : 'Connect with Nostr'}
Créer un compte
</button>
<button
onClick={onUnlock}
disabled={loading}
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors disabled:opacity-50"
>
Se connecter
</button>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)
}
export function ConnectButton() {
const { connected, pubkey, profile, loading, error, connect, disconnect } = useNostrAuth()
function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showCreateModal: boolean, showUnlockModal: boolean, connect: () => Promise<void>) {
useEffect(() => {
if (accountExists === true && !pubkey && !showCreateModal && !showUnlockModal) {
void connect()
}
}, [accountExists, pubkey, showCreateModal, showUnlockModal, connect])
}
if (connected && pubkey) {
return (
<ConnectedUserMenu
pubkey={pubkey}
profile={profile}
onDisconnect={() => {
void disconnect()
}}
function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }) {
return (
<ConnectedUserMenu
pubkey={pubkey}
profile={profile}
onDisconnect={() => {
void disconnect()
}}
loading={loading}
/>
)
}
function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean; error: string | null; onUnlock: () => void; onClose: () => void }) {
return (
<>
<ConnectForm
onCreateAccount={() => {}}
onUnlock={onUnlock}
loading={loading}
error={error}
/>
<UnlockAccountModal onSuccess={onClose} onClose={onClose} />
</>
)
}
function DisconnectedModals({
showCreateModal,
showUnlockModal,
setShowCreateModal,
setShowUnlockModal,
}: {
showCreateModal: boolean
showUnlockModal: boolean
setShowCreateModal: (show: boolean) => void
setShowUnlockModal: (show: boolean) => void
}) {
return (
<>
{showCreateModal && (
<CreateAccountModal
onSuccess={() => {
setShowCreateModal(false)
setShowUnlockModal(true)
}}
onClose={() => setShowCreateModal(false)}
/>
)}
{showUnlockModal && (
<UnlockAccountModal
onSuccess={() => setShowUnlockModal(false)}
onClose={() => setShowUnlockModal(false)}
/>
)}
</>
)
}
function DisconnectedState({
loading,
error,
showCreateModal,
showUnlockModal,
setShowCreateModal,
setShowUnlockModal,
}: {
loading: boolean
error: string | null
showCreateModal: boolean
showUnlockModal: boolean
setShowCreateModal: (show: boolean) => void
setShowUnlockModal: (show: boolean) => void
}) {
return (
<>
<ConnectForm
onCreateAccount={() => setShowCreateModal(true)}
onUnlock={() => setShowUnlockModal(true)}
loading={loading}
error={error}
/>
<DisconnectedModals
showCreateModal={showCreateModal}
showUnlockModal={showUnlockModal}
setShowCreateModal={setShowCreateModal}
setShowUnlockModal={setShowUnlockModal}
/>
</>
)
}
export function ConnectButton() {
const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth()
const [showCreateModal, setShowCreateModal] = useState(false)
const [showUnlockModal, setShowUnlockModal] = useState(false)
useAutoConnect(accountExists, pubkey, showCreateModal, showUnlockModal, connect)
if (connected && pubkey && isUnlocked) {
return <ConnectedState pubkey={pubkey} profile={profile} loading={loading} disconnect={disconnect} />
}
if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal && !showCreateModal) {
return (
<UnlockState
loading={loading}
error={error}
onUnlock={() => setShowUnlockModal(true)}
onClose={() => setShowUnlockModal(false)}
/>
)
}
return (
<ConnectForm
onConnect={() => {
void connect()
}}
<DisconnectedState
loading={loading}
error={error}
showCreateModal={showCreateModal}
showUnlockModal={showUnlockModal}
setShowCreateModal={setShowCreateModal}
setShowUnlockModal={setShowUnlockModal}
/>
)
}

View File

@ -0,0 +1,159 @@
import { useState } from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
import { RecoveryStep, ImportStep, ChooseStep } from './CreateAccountModalSteps'
interface CreateAccountModalProps {
onSuccess: (npub: string) => void
onClose: () => void
}
type Step = 'choose' | 'import' | 'recovery'
async function createAccountWithKey(key?: string) {
return await nostrAuthService.createAccount(key)
}
async function handleAccountCreation(
key: string | undefined,
setLoading: (loading: boolean) => void,
setError: (error: string | null) => void,
setRecoveryPhrase: (phrase: string[]) => void,
setNpub: (npub: string) => void,
setStep: (step: Step) => void,
errorMessage: string
) {
if (key !== undefined && !key.trim()) {
setError('Please enter a private key')
return
}
setLoading(true)
setError(null)
try {
const result = await createAccountWithKey(key?.trim())
setRecoveryPhrase(result.recoveryPhrase)
setNpub(result.npub)
setStep('recovery')
} catch (e) {
setError(e instanceof Error ? e.message : errorMessage)
} finally {
setLoading(false)
}
}
function useAccountCreation() {
const [step, setStep] = useState<Step>('choose')
const [importKey, setImportKey] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
const [npub, setNpub] = useState('')
const handleGenerate = async () => {
await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account')
}
const handleImport = async () => {
await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key')
}
return {
step,
setStep,
importKey,
setImportKey,
loading,
error,
setError,
recoveryPhrase,
npub,
handleGenerate,
handleImport,
}
}
function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void) {
setStep('choose')
setError(null)
setImportKey('')
}
function renderStep(
step: Step,
recoveryPhrase: string[],
npub: string,
importKey: string,
setImportKey: (key: string) => void,
loading: boolean,
error: string | null,
handleContinue: () => void,
handleImport: () => void,
setStep: (step: Step) => void,
setError: (error: string | null) => void,
handleGenerate: () => void,
onClose: () => void
) {
if (step === 'recovery') {
return <RecoveryStep recoveryPhrase={recoveryPhrase} npub={npub} onContinue={handleContinue} />
}
if (step === 'import') {
return (
<ImportStep
importKey={importKey}
setImportKey={setImportKey}
loading={loading}
error={error}
onImport={handleImport}
onBack={() => handleImportBack(setStep, setError, setImportKey)}
/>
)
}
return (
<ChooseStep
loading={loading}
error={error}
onGenerate={handleGenerate}
onImport={() => setStep('import')}
onClose={onClose}
/>
)
}
export function CreateAccountModal({ onSuccess, onClose }: CreateAccountModalProps) {
const {
step,
setStep,
importKey,
setImportKey,
loading,
error,
setError,
recoveryPhrase,
npub,
handleGenerate,
handleImport,
} = useAccountCreation()
const handleContinue = () => {
onSuccess(npub)
onClose()
}
return renderStep(
step,
recoveryPhrase,
npub,
importKey,
setImportKey,
loading,
error,
handleContinue,
handleImport,
setStep,
setError,
handleGenerate,
onClose
)
}

View File

@ -0,0 +1,148 @@
export function RecoveryWarning() {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 font-semibold mb-2"> Important</p>
<p className="text-yellow-700 text-sm">
Ces 4 mots-clés sont votre seule façon de récupérer votre compte.
<strong className="font-bold"> Ils ne seront jamais affichés à nouveau.</strong>
</p>
<p className="text-yellow-700 text-sm mt-2">
Notez-les dans un endroit sûr. Sans ces mots-clés, vous perdrez définitivement l&apos;accès à votre compte.
</p>
</div>
)
}
export function RecoveryPhraseDisplay({
recoveryPhrase,
copied,
onCopy,
}: {
recoveryPhrase: string[]
copied: boolean
onCopy: () => void
}) {
return (
<div className="bg-gray-50 border border-gray-300 rounded-lg p-6 mb-6">
<div className="grid grid-cols-2 gap-4 mb-4">
{recoveryPhrase.map((word, index) => (
<div
key={index}
className="bg-white border border-gray-300 rounded-lg p-3 text-center font-mono text-lg"
>
<span className="text-gray-500 text-sm mr-2">{index + 1}.</span>
<span className="font-semibold">{word}</span>
</div>
))}
</div>
<button
onClick={() => {
void onCopy()
}}
className="w-full py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg text-sm font-medium transition-colors"
>
{copied ? '✓ Copié!' : 'Copier les mots-clés'}
</button>
</div>
)
}
export function PublicKeyDisplay({ npub }: { npub: string }) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-800 font-semibold mb-2">Votre clé publique (npub)</p>
<p className="text-blue-700 text-sm font-mono break-all">{npub}</p>
</div>
)
}
export function ImportKeyForm({
importKey,
setImportKey,
error,
}: {
importKey: string
setImportKey: (key: string) => void
error: string | null
}) {
return (
<>
<div className="mb-4">
<label htmlFor="importKey" className="block text-sm font-medium text-gray-700 mb-2">
Clé privée (nsec ou hex)
</label>
<textarea
id="importKey"
value={importKey}
onChange={(e) => setImportKey(e.target.value)}
placeholder="nsec1..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
rows={4}
/>
</div>
{error && <p className="text-sm text-red-600 mb-4">{error}</p>}
</>
)
}
export function ImportStepButtons({ loading, onImport, onBack }: { loading: boolean; onImport: () => void; onBack: () => void }) {
return (
<div className="flex gap-4">
<button
onClick={onBack}
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
>
Retour
</button>
<button
onClick={() => {
void onImport()
}}
disabled={loading}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{loading ? 'Importation...' : 'Importer'}
</button>
</div>
)
}
export function ChooseStepButtons({
loading,
onGenerate,
onImport,
onClose,
}: {
loading: boolean
onGenerate: () => void
onImport: () => void
onClose: () => void
}) {
return (
<div className="flex flex-col gap-4">
<button
onClick={() => {
void onGenerate()
}}
disabled={loading}
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{loading ? 'Génération...' : 'Générer un nouveau compte'}
</button>
<button
onClick={onImport}
disabled={loading}
className="w-full py-3 px-6 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors disabled:opacity-50"
>
Importer une clé existante
</button>
<button
onClick={onClose}
className="w-full py-2 px-4 text-gray-500 hover:text-gray-700 font-medium transition-colors"
>
Annuler
</button>
</div>
)
}

View File

@ -0,0 +1,94 @@
import { useState } from 'react'
import { RecoveryWarning, RecoveryPhraseDisplay, PublicKeyDisplay, ImportKeyForm, ImportStepButtons, ChooseStepButtons } from './CreateAccountModalComponents'
export function RecoveryStep({
recoveryPhrase,
npub,
onContinue,
}: {
recoveryPhrase: string[]
npub: string
onContinue: () => void
}) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
if (recoveryPhrase.length > 0) {
await navigator.clipboard.writeText(recoveryPhrase.join(' '))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-2xl font-bold mb-4">Sauvegardez vos mots-clés de récupération</h2>
<RecoveryWarning />
<RecoveryPhraseDisplay recoveryPhrase={recoveryPhrase} copied={copied} onCopy={handleCopy} />
<PublicKeyDisplay npub={npub} />
<div className="flex gap-4">
<button
onClick={onContinue}
className="flex-1 py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
J&apos;ai sauvegardé mes mots-clés
</button>
</div>
</div>
</div>
)
}
export function ImportStep({
importKey,
setImportKey,
loading,
error,
onImport,
onBack,
}: {
importKey: string
setImportKey: (key: string) => void
loading: boolean
error: string | null
onImport: () => void
onBack: () => void
}) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4">Importer une clé privée</h2>
<ImportKeyForm importKey={importKey} setImportKey={setImportKey} error={error} />
<ImportStepButtons loading={loading} onImport={onImport} onBack={onBack} />
</div>
</div>
)
}
export function ChooseStep({
loading,
error,
onGenerate,
onImport,
onClose,
}: {
loading: boolean
error: string | null
onGenerate: () => void
onImport: () => void
onClose: () => void
}) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4">Créer un compte</h2>
<p className="text-gray-600 mb-6">
Créez un nouveau compte Nostr ou importez une clé privée existante.
</p>
{error && <p className="text-sm text-red-600 mb-4">{error}</p>}
<ChooseStepButtons loading={loading} onGenerate={onGenerate} onImport={onImport} onClose={onClose} />
</div>
</div>
)
}

View File

@ -2,6 +2,45 @@ import { useEffect, useState } from 'react'
import { estimatePlatformFunds } from '@/lib/fundingCalculation'
import { t } from '@/lib/i18n'
interface FundingProgressBarProps {
progressPercent: number
}
function FundingProgressBar({ progressPercent }: FundingProgressBarProps) {
return (
<div className="relative w-full h-4 bg-cyber-dark rounded-full overflow-hidden border border-neon-cyan/30">
<div
className="absolute top-0 left-0 h-full bg-gradient-to-r from-neon-cyan to-neon-green transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-mono text-cyber-darker font-bold">
{progressPercent.toFixed(1)}%
</span>
</div>
</div>
)
}
function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFunds> }) {
const progressPercent = Math.min(100, stats.progressPercent)
return (
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-cyber-accent">{t('home.funding.current', { current: stats.totalBTC.toFixed(6) })}</span>
<span className="text-cyber-accent">{t('home.funding.target', { target: stats.targetBTC.toFixed(2) })}</span>
</div>
<FundingProgressBar progressPercent={progressPercent} />
<p className="text-xs text-cyber-accent/70">
{t('home.funding.progress', { percent: progressPercent.toFixed(1) })}
</p>
<p className="text-sm text-cyber-accent mt-4">
{t('home.funding.description')}
</p>
</div>
)
}
export function FundingGauge() {
const [stats, setStats] = useState(estimatePlatformFunds())
const [loading, setLoading] = useState(true)
@ -30,38 +69,10 @@ export function FundingGauge() {
)
}
const progressPercent = Math.min(100, stats.progressPercent)
return (
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('home.funding.title')}</h2>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-cyber-accent">{t('home.funding.current', { current: stats.totalBTC.toFixed(6) })}</span>
<span className="text-cyber-accent">{t('home.funding.target', { target: stats.targetBTC.toFixed(2) })}</span>
</div>
<div className="relative w-full h-4 bg-cyber-dark rounded-full overflow-hidden border border-neon-cyan/30">
<div
className="absolute top-0 left-0 h-full bg-gradient-to-r from-neon-cyan to-neon-green transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-mono text-cyber-darker font-bold">
{progressPercent.toFixed(1)}%
</span>
</div>
</div>
<p className="text-xs text-cyber-accent/70">
{t('home.funding.progress', { percent: progressPercent.toFixed(1) })}
</p>
<p className="text-sm text-cyber-accent mt-4">
{t('home.funding.description')}
</p>
</div>
<FundingStats stats={stats} />
</div>
)
}

View File

@ -59,10 +59,10 @@ function HomeIntroSection() {
<div className="mt-12 mb-8">
<div className="mb-6 text-cyber-accent leading-relaxed bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-4 backdrop-blur-sm">
<p className="mb-2">
Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par <strong className="text-neon-green">800 sats</strong> (moins 100 sats et frais de transaction).
Consultez les auteurs et aperçus, achetez les parutions au fil de l&apos;eau par <strong className="text-neon-green">800 sats</strong> (moins 100 sats et frais de transaction).
</p>
<p className="mb-2">
Sponsorisez l'auteur pour <strong className="text-neon-green">0.046 BTC</strong> (moins 0.004 BTC et frais de transaction).
Sponsorisez l&apos;auteur pour <strong className="text-neon-green">0.046 BTC</strong> (moins 0.004 BTC et frais de transaction).
</p>
<p className="mb-2">
Les avis sont remerciables pour <strong className="text-neon-green">70 sats</strong> (moins 21 sats et frais de transaction).

View File

@ -11,7 +11,87 @@ interface ImageUploadFieldProps {
helpText?: string | undefined
}
export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps) {
function ImagePreview({ value }: { value: string }) {
return (
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20">
<Image
src={value}
alt={t('presentation.field.picture')}
fill
className="object-cover"
/>
</div>
)
}
function UploadButtonLabel({ uploading, value }: { uploading: boolean; value: string | undefined }) {
if (uploading) {
return <>{t('presentation.field.picture.uploading')}</>
}
return <>{value ? t('presentation.field.picture.change') : t('presentation.field.picture.upload')}</>
}
function RemoveButton({ value, onChange }: { value: string | undefined; onChange: (url: string) => void }) {
if (!value) {
return null
}
return (
<button
type="button"
onClick={() => onChange('')}
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50"
>
{t('presentation.field.picture.remove')}
</button>
)
}
function ImageUploadControls({
id,
uploading,
value,
onChange,
onFileSelect,
}: {
id: string
uploading: boolean
value: string | undefined
onChange: (url: string) => void
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
}) {
return (
<div className="flex items-center gap-2">
<label
htmlFor={id}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan cursor-pointer"
>
<UploadButtonLabel uploading={uploading} value={value} />
</label>
<input
id={id}
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
className="hidden"
onChange={(e) => {
void onFileSelect(e)
}}
disabled={uploading}
/>
<RemoveButton value={value} onChange={onChange} />
</div>
)
}
async function processFileUpload(file: File, onChange: (url: string) => void, setError: (error: string | null) => void) {
const media = await uploadNip95Media(file)
if (media.type === 'image') {
onChange(media.url)
} else {
setError(t('presentation.field.picture.error.imagesOnly'))
}
}
function useImageUpload(onChange: (url: string) => void) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -25,12 +105,7 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
setUploading(true)
try {
const media = await uploadNip95Media(file)
if (media.type === 'image') {
onChange(media.url)
} else {
setError(t('presentation.field.picture.error.imagesOnly'))
}
await processFileUpload(file, onChange, setError)
} catch (e) {
setError(e instanceof Error ? e.message : t('presentation.field.picture.error.uploadFailed'))
} finally {
@ -38,6 +113,11 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
}
}
return { uploading, error, handleFileSelect }
}
export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps) {
const { uploading, error, handleFileSelect } = useImageUpload(onChange)
const displayLabel = label ?? t('presentation.field.picture')
const displayHelpText = helpText ?? t('presentation.field.picture.help')
@ -46,47 +126,16 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
<label htmlFor={id} className="block text-sm font-medium text-neon-cyan">
{displayLabel}
</label>
{value && (
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20">
<Image
src={value}
alt={t('presentation.field.picture')}
fill
className="object-cover"
/>
</div>
)}
<div className="flex items-center gap-2">
<label
htmlFor={id}
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan cursor-pointer"
>
{uploading ? t('presentation.field.picture.uploading') : value ? t('presentation.field.picture.change') : t('presentation.field.picture.upload')}
</label>
<input
id={id}
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
className="hidden"
onChange={handleFileSelect}
disabled={uploading}
/>
{value && (
<button
type="button"
onClick={() => onChange('')}
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50"
>
{t('presentation.field.picture.remove')}
</button>
)}
</div>
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
{displayHelpText && (
<p className="text-sm text-cyber-accent">{displayHelpText}</p>
)}
{value && <ImagePreview value={value} />}
<ImageUploadControls
id={id}
uploading={uploading}
value={value}
onChange={onChange}
onFileSelect={handleFileSelect}
/>
{error && <p className="text-sm text-red-400">{error}</p>}
{displayHelpText && <p className="text-sm text-cyber-accent">{displayHelpText}</p>}
</div>
)
}

View File

@ -3,6 +3,29 @@ import { setLocale, getLocale, type Locale } from '@/lib/i18n'
const LOCALE_STORAGE_KEY = 'zapwall-locale'
interface LocaleButtonProps {
locale: Locale
label: string
currentLocale: Locale
onClick: (locale: Locale) => void
}
function LocaleButton({ locale, label, currentLocale, onClick }: LocaleButtonProps) {
const isActive = currentLocale === locale
return (
<button
onClick={() => onClick(locale)}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
isActive
? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
: 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
}`}
>
{label}
</button>
)
}
export function LanguageSelector() {
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
@ -27,26 +50,8 @@ export function LanguageSelector() {
return (
<div className="flex items-center gap-2">
<button
onClick={() => handleLocaleChange('fr')}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
currentLocale === 'fr'
? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
: 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
}`}
>
FR
</button>
<button
onClick={() => handleLocaleChange('en')}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
currentLocale === 'en'
? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
: 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
}`}
>
EN
</button>
<LocaleButton locale="fr" label="FR" currentLocale={currentLocale} onClick={handleLocaleChange} />
<LocaleButton locale="en" label="EN" currentLocale={currentLocale} onClick={handleLocaleChange} />
</div>
)
}

View File

@ -0,0 +1,159 @@
import { useState } from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
interface UnlockAccountModalProps {
onSuccess: () => void
onClose: () => void
}
function WordInputs({
words,
onWordChange,
}: {
words: string[]
onWordChange: (index: number, value: string) => void
}) {
return (
<div className="grid grid-cols-2 gap-4">
{words.map((word, index) => (
<div key={index}>
<label htmlFor={`word-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
Mot {index + 1}
</label>
<input
id={`word-${index}`}
type="text"
value={word}
onChange={(e) => onWordChange(index, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
/>
</div>
))}
</div>
)
}
function useUnlockAccount(words: string[], setWords: (words: string[]) => void, setError: (error: string | null) => void) {
const handleWordChange = (index: number, value: string) => {
const newWords = [...words]
newWords[index] = value.trim().toLowerCase()
setWords(newWords)
setError(null)
}
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText()
const pastedWords = text.trim().split(/\s+/).slice(0, 4)
if (pastedWords.length === 4) {
setWords(pastedWords.map((w) => w.toLowerCase()))
setError(null)
}
} catch (_e) {
// Ignore clipboard errors
}
}
return { handleWordChange, handlePaste }
}
function UnlockAccountButtons({
loading,
words,
onUnlock,
onClose,
}: {
loading: boolean
words: string[]
onUnlock: () => void
onClose: () => void
}) {
return (
<div className="flex gap-4">
<button
onClick={onClose}
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
>
Annuler
</button>
<button
onClick={() => {
void onUnlock()
}}
disabled={loading || words.some((word) => !word)}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{loading ? 'Déverrouillage...' : 'Déverrouiller'}
</button>
</div>
)
}
function UnlockAccountForm({
words,
handleWordChange,
handlePaste,
}: {
words: string[]
handleWordChange: (index: number, value: string) => void
handlePaste: () => void
}) {
return (
<div className="mb-4">
<WordInputs words={words} onWordChange={handleWordChange} />
<button
onClick={() => {
void handlePaste()
}}
className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
>
Coller depuis le presse-papiers
</button>
</div>
)
}
export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalProps) {
const [words, setWords] = useState(['', '', '', ''])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { handleWordChange, handlePaste } = useUnlockAccount(words, setWords, setError)
const handleUnlock = async () => {
if (words.some((word) => !word)) {
setError('Veuillez remplir tous les mots-clés')
return
}
setLoading(true)
setError(null)
try {
await nostrAuthService.unlockAccount(words)
onSuccess()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Échec du déverrouillage. Vérifiez vos mots-clés.')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4">Déverrouiller votre compte</h2>
<p className="text-gray-600 mb-6">
Entrez vos 4 mots-clés de récupération pour déverrouiller votre compte.
</p>
<UnlockAccountForm words={words} handleWordChange={handleWordChange} handlePaste={handlePaste} />
{error && <p className="text-sm text-red-600 mb-4">{error}</p>}
<UnlockAccountButtons loading={loading} words={words} onUnlock={handleUnlock} onClose={onClose} />
</div>
</div>
)
}

204
deploy.sh Normal file
View File

@ -0,0 +1,204 @@
#!/bin/bash
set -e
# Configuration
SERVER="debian@92.243.27.35"
APP_NAME="zapwall"
DOMAIN="zapwall.fr"
APP_DIR="/var/www/${DOMAIN}"
GIT_REPO="https://git.4nkweb.com/4nk/story-research-zapwall.git"
# Vérifier qu'un message de commit est fourni
if [ -z "$1" ]; then
echo "Erreur: Un message de commit est requis"
echo ""
echo "Usage: ./deploy.sh \"Message de commit\""
echo ""
echo "Exemple: ./deploy.sh \"Fix: Correction du bug de connexion\""
exit 1
fi
COMMIT_MESSAGE="$1"
echo "=== Déploiement de ${DOMAIN} ==="
echo ""
# Détecter la branche courante
BRANCH=$(git branch --show-current 2>/dev/null || echo "main")
echo "Branche courante: ${BRANCH}"
echo "Message de commit: ${COMMIT_MESSAGE}"
echo ""
# Commit et push des modifications locales
echo "1. Préparation du commit local..."
HAS_CHANGES=false
HAS_UNPUSHED_COMMITS=false
# Vérifier s'il y a des modifications non commitées
if ! git diff --quiet || ! git diff --cached --quiet; then
HAS_CHANGES=true
fi
# Vérifier s'il y a des commits non poussés
if git rev-parse --abbrev-ref ${BRANCH}@{upstream} >/dev/null 2>&1; then
LOCAL=$(git rev-parse @)
REMOTE=$(git rev-parse @{u})
if [ "$LOCAL" != "$REMOTE" ]; then
HAS_UNPUSHED_COMMITS=true
fi
else
# Pas de branche distante configurée, vérifier s'il y a des commits locaux
if git rev-list --count origin/${BRANCH}..HEAD >/dev/null 2>&1; then
UNPUSHED_COUNT=$(git rev-list --count origin/${BRANCH}..HEAD 2>/dev/null || echo "0")
if [ "$UNPUSHED_COUNT" -gt 0 ]; then
HAS_UNPUSHED_COMMITS=true
fi
fi
fi
if [ "$HAS_CHANGES" = true ]; then
echo " ✓ Modifications détectées"
echo ""
echo "2. Ajout des modifications..."
git add -A
echo " ✓ Fichiers ajoutés"
echo ""
echo "3. Création du commit..."
git commit -m "${COMMIT_MESSAGE}"
echo " ✓ Commit créé"
HAS_UNPUSHED_COMMITS=true
fi
if [ "$HAS_UNPUSHED_COMMITS" = true ]; then
echo ""
echo "4. Push vers le dépôt distant..."
git push origin ${BRANCH}
echo " ✓ Push effectué"
else
echo " ⚠ Aucune modification à commiter ni commit à pousser"
fi
# Vérifier si Git est initialisé sur le serveur
echo ""
echo "5. Vérification du dépôt Git sur le serveur..."
if ssh ${SERVER} "cd ${APP_DIR} && git status >/dev/null 2>&1"; then
echo " ✓ Dépôt Git détecté"
else
echo " ⚠ Dépôt Git non initialisé, initialisation..."
ssh ${SERVER} "cd ${APP_DIR} && git init && git remote add origin ${GIT_REPO} 2>/dev/null || git remote set-url origin ${GIT_REPO}"
ssh ${SERVER} "cd ${APP_DIR} && git checkout -b ${BRANCH} 2>/dev/null || true"
fi
# Récupérer les dernières modifications
echo ""
echo "6. Récupération des dernières modifications..."
ssh ${SERVER} "cd ${APP_DIR} && git fetch origin"
# Sauvegarder les modifications locales sur le serveur
echo ""
echo "7. Sauvegarde des modifications locales sur le serveur..."
STASH_OUTPUT=$(ssh ${SERVER} "cd ${APP_DIR} && git stash push -u -m 'Auto-stash before deploy - $(date +%Y-%m-%d_%H:%M:%S)' 2>&1" || echo "No changes to stash")
if echo "$STASH_OUTPUT" | grep -q "No local changes"; then
echo " ✓ Aucune modification locale à sauvegarder"
else
echo " ✓ Modifications locales sauvegardées"
fi
# Nettoyer les fichiers non suivis
echo ""
echo "8. Nettoyage des fichiers non suivis..."
ssh ${SERVER} "cd ${APP_DIR} && git clean -fd || true"
# Vérifier que la branche existe
echo ""
echo "9. Vérification de la branche ${BRANCH}..."
if ssh ${SERVER} "cd ${APP_DIR} && git ls-remote --heads origin ${BRANCH} | grep -q ${BRANCH}"; then
echo " ✓ Branche ${BRANCH} trouvée"
else
echo " ✗ Branche ${BRANCH} non trouvée sur le dépôt distant"
echo ""
echo " Branches disponibles:"
AVAILABLE_BRANCHES=$(ssh ${SERVER} "cd ${APP_DIR} && git ls-remote --heads origin | sed 's/.*refs\\/heads\\///'")
echo "$AVAILABLE_BRANCHES" | sed 's/^/ - /'
echo ""
echo " Erreur: La branche '${BRANCH}' n'existe pas sur le dépôt distant."
echo " Vérifiez que vous avez bien poussé la branche avec 'git push origin ${BRANCH}'"
exit 1
fi
# Mise à jour depuis la branche
echo ""
echo "10. Mise à jour depuis la branche ${BRANCH}..."
ssh ${SERVER} "cd ${APP_DIR} && git checkout ${BRANCH} 2>/dev/null || git checkout -b ${BRANCH} origin/${BRANCH}"
ssh ${SERVER} "cd ${APP_DIR} && git pull origin ${BRANCH}"
# Afficher le dernier commit
echo ""
echo "11. Dernier commit:"
ssh ${SERVER} "cd ${APP_DIR} && git log -1 --oneline"
# Copier next.config.js local vers le serveur (pour ignorer ESLint pendant le build)
echo ""
echo "12. Mise à jour de next.config.js pour ignorer ESLint pendant le build..."
if [ -f "next.config.js" ]; then
cat next.config.js | ssh ${SERVER} "cat > ${APP_DIR}/next.config.js"
echo " ✓ next.config.js mis à jour"
else
echo " ⚠ next.config.js local non trouvé, utilisation de celui du serveur"
fi
# Installer les dépendances
echo ""
echo "13. Installation des dépendances..."
ssh ${SERVER} "cd ${APP_DIR} && npm ci"
# Construire l'application
echo ""
echo "14. Construction de l'application..."
ssh ${SERVER} "cd ${APP_DIR} && npm run build"
# Redémarrer le service
echo ""
echo "15. Redémarrage du service ${APP_NAME}..."
ssh ${SERVER} "sudo systemctl restart ${APP_NAME}"
sleep 3
# Vérifier que le service fonctionne
echo ""
echo "16. Vérification du service..."
if ssh ${SERVER} "sudo systemctl is-active ${APP_NAME} >/dev/null"; then
echo " ✓ Service actif"
echo ""
echo " Statut du service:"
ssh ${SERVER} "sudo systemctl status ${APP_NAME} --no-pager | head -10"
else
echo " ✗ Service inactif, vérification des logs..."
ssh ${SERVER} "sudo journalctl -u ${APP_NAME} --no-pager -n 30"
exit 1
fi
# Vérifier que le port est en écoute
echo ""
echo "17. Vérification du port 3001..."
if ssh ${SERVER} "sudo ss -tuln | grep -q ':3001 '"; then
echo " ✓ Port 3001 en écoute"
else
echo " ⚠ Port 3001 non encore en écoute, attente..."
sleep 5
if ssh ${SERVER} "sudo ss -tuln | grep -q ':3001 '"; then
echo " ✓ Port 3001 maintenant en écoute"
else
echo " ✗ Port 3001 toujours non en écoute"
exit 1
fi
fi
echo ""
echo "=== Déploiement terminé avec succès ==="
echo ""
echo "Site disponible sur: https://${DOMAIN}/"
echo ""
echo "Commandes utiles:"
echo " Voir les logs: ssh ${SERVER} 'sudo journalctl -u ${APP_NAME} -f'"
echo " Voir les stashes: ssh ${SERVER} 'cd ${APP_DIR} && git stash list'"
echo " Restaurer un stash: ssh ${SERVER} 'cd ${APP_DIR} && git stash pop'"

133
docs/DOCUMENTATION.md Normal file
View File

@ -0,0 +1,133 @@
# Documentation complète - zapwall.fr
## 📚 Index de la documentation
### 🚀 Déploiement et infrastructure
#### Documentation principale
1. **[Documentation complète du déploiement](docs/deployment.md)**
- Vue d'ensemble de l'architecture
- Configuration initiale
- Mise à jour du site (Git, transfert manuel)
- Configuration HTTPS (auto-signé et Let's Encrypt)
- Scripts disponibles
- Dépannage complet
- Maintenance et commandes utiles
2. **[Référence des scripts](docs/scripts-reference.md)**
- Liste complète de tous les scripts
- Description détaillée de chaque script
- Paramètres et options
- Ordre d'exécution recommandé
3. **[Guide de référence rapide](docs/quick-reference.md)**
- Commandes essentielles en un coup d'œil
- Informations importantes
- Liens rapides vers la documentation
#### Guides pratiques
4. **[README-DEPLOYMENT.md](README-DEPLOYMENT.md)**
- Guide de déploiement et mise à jour
- Méthodes de mise à jour
- Commandes utiles
- Configuration HTTPS
5. **[RESUME-DEPLOIEMENT.md](RESUME-DEPLOIEMENT.md)**
- Résumé du déploiement
- État actuel
- Problèmes identifiés et solutions
- Prochaines étapes
### 📝 Scripts de déploiement
#### Scripts principaux
- **`deploy.sh`** : Déploiement initial complet avec vérifications
- **`update-remote-git.sh`** : Mise à jour via Git (stash + pull + rebuild) ⭐ **Recommandé**
- **`update-from-git.sh`** : Mise à jour depuis dépôt local
- **`finish-deploy.sh`** : Finalisation du déploiement
#### Scripts de vérification
- **`check-deploy.sh`** : Vérification préalable avant déploiement
- **`check-deployment-status.sh`** : État complet du déploiement
- **`check-nginx-config.sh`** : Vérification de la configuration nginx
- **`check-git-repo.sh`** : Vérification du dépôt Git
- **`final-status.sh`** : Résumé de l'état final
#### Scripts de configuration
- **`setup-https-autosigned.sh`** : Configuration HTTPS avec certificats auto-signés
- **`deploy-letsencrypt.sh`** : Déploiement des certificats Let's Encrypt
- **`open-firewall-ports.sh`** : Ouverture des ports 80/443
- **`fix-nginx-config.sh`** : Correction de la configuration
### 🔧 Informations techniques
#### Serveur
- **Adresse** : `92.243.27.35`
- **Utilisateur** : `debian`
- **Domaine** : `zapwall.fr`
- **Répertoire** : `/var/www/zapwall.fr`
- **Port application** : `3001`
- **Service** : `zapwall.service` (systemd)
- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
#### Architecture
```
Internet → Firewall (80/443) → Nginx Docker → Port 3001 → Next.js App
```
### 📖 Documentation utilisateur
- **[Guide utilisateur](docs/user-guide.md)** : Guide d'utilisation de la plateforme
- **[FAQ](docs/faq.md)** : Questions fréquentes
- **[Guide de publication](docs/publishing-guide.md)** : Comment publier un article
- **[Guide de paiement](docs/payment-guide.md)** : Comment effectuer un paiement
### 🔬 Documentation technique
- **[Documentation technique](docs/technical.md)** : Architecture technique
- **[Configuration stricte](docs/STRICT_CONFIG_SUMMARY.md)** : Règles de qualité du code
- **[Configuration Rizful API](docs/rizful-api-setup.md)** : Configuration de l'API Rizful
### 📋 Spécifications
- **[Fonctionnalités](features/features.md)** : Liste des fonctionnalités
- **[Notifications](features/notifications-implementation.md)** : Implémentation des notifications
- **[Séries et médias](features/series-and-media-spec.md)** : Spécification des séries
- **[Refactoring](features/zapwall4science-refactoring.md)** : Notes de refactoring
## 🎯 Démarrage rapide
### Pour déployer ou mettre à jour
```bash
# Déploiement depuis la branche main (par défaut)
./deploy.sh
# Déploiement depuis une autre branche
./deploy.sh develop
```
Le script `deploy.sh` effectue automatiquement :
- Mise à jour depuis Git
- Installation des dépendances
- Construction de l'application
- Redémarrage du service
## 📞 Support
En cas de problème :
1. Consulter [docs/deployment.md - Section Dépannage](docs/deployment.md#dépannage)
2. Vérifier les logs : `ssh debian@92.243.27.35 'sudo journalctl -u zapwall -n 100'`
3. Utiliser les scripts de vérification
---
*Dernière mise à jour : 2025-12-28*

200
docs/README-DEPLOYMENT.md Normal file
View File

@ -0,0 +1,200 @@
# Guide de déploiement et mise à jour - zapwall.fr
## État actuel
- **Service**: zapwall.service (systemd)
- **Répertoire**: `/var/www/zapwall.fr`
- **Port application**: 3001
- **Nginx**: Conteneur Docker `lecoffre_nginx_test`
- **HTTPS**: Configuré avec redirection automatique HTTP → HTTPS
## Mise à jour du site depuis Git
### Méthode 1 : Si le dépôt Git est déjà cloné sur le serveur
```bash
# Se connecter au serveur
ssh debian@92.243.27.35
# Aller dans le répertoire de l'application
cd /var/www/zapwall.fr
# Récupérer les dernières modifications
git fetch origin
# Basculer sur la branche souhaitée (par défaut: main)
git checkout main # ou master, ou une autre branche
# Récupérer les modifications
git pull origin main
# Installer les dépendances
npm ci
# Construire l'application
npm run build
# Redémarrer le service
sudo systemctl restart zapwall
# Vérifier que le service fonctionne
sudo systemctl status zapwall
```
### Méthode 2 : Utiliser le script de mise à jour
Depuis votre machine locale :
```bash
# Mise à jour depuis la branche main
./update-from-git.sh main
# Ou depuis une autre branche
./update-from-git.sh master
```
Le script :
1. Se connecte au serveur
2. Récupère les modifications depuis Git
3. Installe les dépendances
4. Construit l'application
5. Redémarre le service
6. Vérifie que tout fonctionne
### Méthode 3 : Transfert manuel depuis le dépôt local
Si le dépôt Git n'est pas sur le serveur :
```bash
# Depuis votre machine locale, dans le répertoire du projet
tar --exclude='node_modules' \
--exclude='.next' \
--exclude='.git' \
--exclude='*.tsbuildinfo' \
--exclude='.env*.local' \
--exclude='.cursor' \
-czf - . | ssh debian@92.243.27.35 "cd /var/www/zapwall.fr && tar -xzf -"
# Puis sur le serveur
ssh debian@92.243.27.35
cd /var/www/zapwall.fr
npm ci
npm run build
sudo systemctl restart zapwall
```
## Commandes utiles
### Voir les logs du service
```bash
ssh debian@92.243.27.35 'sudo journalctl -u zapwall -f'
```
### Vérifier le statut du service
```bash
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
```
### Redémarrer le service
```bash
ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
```
### Vérifier que le port 3001 est en écoute
```bash
ssh debian@92.243.27.35 'sudo ss -tuln | grep 3001'
```
### Vérifier la configuration nginx
```bash
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -t'
```
### Recharger nginx après modification
```bash
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -s reload'
```
## Configuration HTTPS
Actuellement, HTTPS est configuré avec des certificats auto-signés. Pour obtenir des certificats Let's Encrypt valides :
### Option 1 : Utiliser certbot via snap (recommandé)
```bash
ssh debian@92.243.27.35
sudo snap install certbot --classic
sudo docker stop lecoffre_nginx_test
sudo certbot certonly --standalone -d zapwall.fr --non-interactive --agree-tos --email admin@zapwall.fr
sudo docker start lecoffre_nginx_test
# Copier les certificats dans le conteneur
sudo docker cp /etc/letsencrypt/live/zapwall.fr/fullchain.pem lecoffre_nginx_test:/etc/letsencrypt/live/zapwall.fr/fullchain.pem
sudo docker cp /etc/letsencrypt/live/zapwall.fr/privkey.pem lecoffre_nginx_test:/etc/letsencrypt/live/zapwall.fr/privkey.pem
# Mettre à jour la configuration nginx pour utiliser les certificats Let's Encrypt
# (modifier ssl_certificate et ssl_certificate_key dans /etc/nginx/conf.d/zapwall.fr.conf)
sudo docker exec lecoffre_nginx_test nginx -s reload
```
### Option 2 : Utiliser acme.sh
```bash
ssh debian@92.243.27.35
curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --issue -d zapwall.fr --standalone
```
## Structure des fichiers
```
/var/www/zapwall.fr/ # Répertoire de l'application
├── .next/ # Build de production Next.js
├── node_modules/ # Dépendances npm
├── pages/ # Pages Next.js
├── components/ # Composants React
├── lib/ # Bibliothèques
└── package.json # Configuration npm
/etc/systemd/system/zapwall.service # Service systemd
/etc/nginx/conf.d/zapwall.fr.conf # Configuration nginx (dans le conteneur)
```
## Dépannage
### Le service ne démarre pas
```bash
# Voir les logs
ssh debian@92.243.27.35 'sudo journalctl -u zapwall -n 50'
# Vérifier que le répertoire existe
ssh debian@92.243.27.35 'ls -la /var/www/zapwall.fr'
# Vérifier que l'application est construite
ssh debian@92.243.27.35 'ls -la /var/www/zapwall.fr/.next'
```
### Le port 3001 n'est pas en écoute
```bash
# Vérifier que le service est actif
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
# Redémarrer le service
ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
```
### Nginx ne sert pas le bon site
```bash
# Vérifier la configuration
ssh debian@92.243.35 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
# Vérifier que proxy_pass pointe vers 172.17.0.1:3001
# Vérifier que server_name contient zapwall.fr
```
## Notes importantes
- Le service zapwall doit être actif pour que l'application soit accessible
- Nginx fait un reverse proxy vers le port 3001
- Les modifications de code nécessitent un rebuild (`npm run build`) et un redémarrage du service
- Les certificats Let's Encrypt doivent être renouvelés tous les 90 jours

75
docs/README.md Normal file
View File

@ -0,0 +1,75 @@
# Documentation du projet zapwall.fr
## 📚 Documentation disponible
### Déploiement et infrastructure
- **[Documentation complète du déploiement](deployment.md)**
- Architecture du déploiement
- Configuration initiale
- Mise à jour du site
- Configuration HTTPS
- Dépannage
- Maintenance
- **[Référence des scripts](scripts-reference.md)**
- Description de tous les scripts
- Utilisation et paramètres
- Ordre d'exécution recommandé
- **[Guide de référence rapide](quick-reference.md)**
- Commandes essentielles
- Informations importantes
- Liens rapides
### Documentation utilisateur
- **[Guide utilisateur](../docs/user-guide.md)** : Guide d'utilisation de la plateforme
- **[FAQ](../docs/faq.md)** : Questions fréquentes
- **[Guide de publication](../docs/publishing-guide.md)** : Comment publier un article
- **[Guide de paiement](../docs/payment-guide.md)** : Comment effectuer un paiement
### Documentation technique
- **[Documentation technique](../docs/technical.md)** : Architecture technique
- **[Configuration stricte](../docs/STRICT_CONFIG_SUMMARY.md)** : Règles de qualité du code
- **[Configuration Rizful API](../docs/rizful-api-setup.md)** : Configuration de l'API Rizful
### Spécifications
- **[Fonctionnalités](../features/features.md)** : Liste des fonctionnalités
- **[Notifications](../features/notifications-implementation.md)** : Implémentation des notifications
- **[Séries et médias](../features/series-and-media-spec.md)** : Spécification des séries
- **[Refactoring](../features/zapwall4science-refactoring.md)** : Notes de refactoring
## 🚀 Démarrage rapide
### Développement local
```bash
npm install
npm run dev
```
### Déploiement
```bash
# Vérification préalable
./check-deploy.sh
# Déploiement initial
./deploy.sh
# Mise à jour
./update-remote-git.sh
```
## 📖 Navigation
- Pour le **déploiement** : Commencez par [deployment.md](deployment.md)
- Pour les **scripts** : Consultez [scripts-reference.md](scripts-reference.md)
- Pour les **commandes rapides** : Voir [quick-reference.md](quick-reference.md)
---
*Dernière mise à jour : 2025-12-28*

611
docs/deployment.md Normal file
View File

@ -0,0 +1,611 @@
# Documentation complète du déploiement - zapwall.fr
## 📋 Table des matières
1. [Vue d'ensemble](#vue-densemble)
2. [Architecture du déploiement](#architecture-du-déploiement)
3. [Configuration initiale](#configuration-initiale)
4. [Mise à jour du site](#mise-à-jour-du-site)
5. [Configuration HTTPS](#configuration-https)
6. [Scripts disponibles](#scripts-disponibles)
7. [Dépannage](#dépannage)
8. [Maintenance](#maintenance)
---
## Vue d'ensemble
### Informations du serveur
- **Serveur** : `<IP>`
- **Utilisateur** : `<USER>`
- **Domaine** : `zapwall.fr`
- **Répertoire de l'application** : `/var/www/zapwall.fr`
- **Port application** : `3001`
- **Service systemd** : `zapwall.service`
- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
### État actuel
- ✅ Application déployée et fonctionnelle
- ✅ Service systemd actif
- ✅ HTTPS configuré avec redirection automatique
- ✅ Firewall configuré (ports 80/443 ouverts)
- ✅ Certificats Let's Encrypt configurés (valides jusqu'au 2026-03-28)
---
## Architecture du déploiement
### Schéma d'architecture
```
Internet
[Port 80/443] ← Firewall (UFW)
[Nginx Docker Container] lecoffre_nginx_test
│ (reverse proxy)
[Port 3001] ← Application Next.js
[zapwall.service] (systemd)
[/var/www/zapwall.fr] (répertoire de l'application)
```
### Composants
1. **Nginx (Docker)** : Reverse proxy gérant HTTP/HTTPS
- Conteneur : `lecoffre_nginx_test`
- Configuration : `/etc/nginx/conf.d/zapwall.fr.conf` (dans le conteneur)
- Ports : 80 (HTTP), 443 (HTTPS)
2. **Application Next.js** : Application principale
- Service : `zapwall.service` (systemd)
- Port : 3001
- Répertoire : `/var/www/zapwall.fr`
3. **Git** : Dépôt source
- URL : `https://git.4nkweb.com/4nk/story-research-zapwall.git`
- Branche par défaut : `main`
---
## Configuration initiale
### Prérequis
- Accès SSH au serveur
- Docker installé et configuré
- Node.js et npm installés
- Git installé
### Structure des fichiers sur le serveur
```
/var/www/zapwall.fr/ # Répertoire de l'application
├── .next/ # Build de production Next.js
├── .git/ # Dépôt Git (si initialisé)
├── node_modules/ # Dépendances npm
├── pages/ # Pages Next.js
├── components/ # Composants React
├── lib/ # Bibliothèques
├── hooks/ # Hooks React
├── public/ # Fichiers statiques
├── styles/ # Styles CSS
├── package.json # Configuration npm
└── next.config.js # Configuration Next.js
/etc/systemd/system/zapwall.service # Service systemd
```
### Service systemd
Le service `zapwall.service` est configuré ainsi :
```ini
[Unit]
Description=Zapwall Next.js Application
After=network.target
[Service]
Type=simple
User=<USER>
WorkingDirectory=/var/www/zapwall.fr
Environment=NODE_ENV=production
Environment=PORT=3001
ExecStart=/usr/bin/node /var/www/zapwall.fr/node_modules/.bin/next start -p 3001
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
### Configuration Nginx
La configuration nginx pour zapwall.fr se trouve dans le conteneur Docker à :
`/etc/nginx/conf.d/zapwall.fr.conf`
Elle configure :
- Redirection HTTP → HTTPS
- Reverse proxy vers `http://172.17.0.1:3001`
- Headers de sécurité (HSTS, X-Frame-Options, etc.)
- Support SSL/TLS
---
## Mise à jour du site
### Méthode recommandée : Script automatique
Le script `deploy.sh` effectue automatiquement :
1. Stash des modifications locales
2. Pull depuis Git
3. Installation des dépendances
4. Construction de l'application
5. Redémarrage du service
**Utilisation :**
```bash
# Depuis votre machine locale
./deploy.sh
# Ou depuis une autre branche
./deploy.sh develop
```
### Méthode manuelle : Git sur le serveur
```bash
# Se connecter au serveur
ssh <USER>@<IP>
# Aller dans le répertoire
cd /var/www/zapwall.fr
# Sauvegarder les modifications locales
git stash
# Récupérer les dernières modifications
git pull origin main
# Installer les dépendances
npm ci
# Construire l'application
npm run build
# Redémarrer le service
sudo systemctl restart zapwall
# Vérifier le statut
sudo systemctl status zapwall
```
### Méthode alternative : Transfert depuis dépôt local
Si Git n'est pas configuré sur le serveur :
```bash
# Depuis votre machine locale
tar --exclude='node_modules' \
--exclude='.next' \
--exclude='.git' \
--exclude='*.tsbuildinfo' \
--exclude='.env*.local' \
--exclude='.cursor' \
-czf - . | ssh <USER>@<IP> "cd /var/www/zapwall.fr && tar -xzf -"
# Puis sur le serveur
ssh <USER>@<IP>
cd /var/www/zapwall.fr
npm ci
npm run build
sudo systemctl restart zapwall
```
### Gestion des stashes Git
```bash
# Voir les stashes
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash list'
# Restaurer le dernier stash
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash pop'
# Supprimer un stash
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash drop stash@{0}'
```
---
## Configuration HTTPS
### État actuel
**Certificats Let's Encrypt configurés** pour `zapwall.fr` (obtenus via certbot snap).
Les certificats sont valides jusqu'au **2026-03-28** et seront renouvelés automatiquement.
### Configuration des certificats Let's Encrypt
Les certificats ont été obtenus via **certbot snap** (pour éviter le bug avec certbot et Python 3.11).
#### Méthode utilisée
1. **Installation de certbot via snap** :
```bash
sudo snap install certbot --classic
```
2. **Obtention des certificats** (mode standalone, nginx arrêté) :
```bash
sudo docker stop lecoffre_nginx_test
sudo certbot certonly --standalone \
-d zapwall.fr \
--non-interactive \
--agree-tos \
--email admin@zapwall.fr
sudo docker start lecoffre_nginx_test
```
3. **Copie des certificats dans le volume monté** :
- Les certificats sont stockés dans `/etc/letsencrypt/live/zapwall.fr/` sur l'hôte
- Ils sont copiés dans `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/`
- Ce répertoire est monté dans le conteneur nginx en lecture seule à `/etc/letsencrypt`
4. **Mise à jour de la configuration nginx** :
- `ssl_certificate /etc/letsencrypt/live/zapwall.fr/fullchain.pem;`
- `ssl_certificate_key /etc/letsencrypt/live/zapwall.fr/privkey.pem;`
#### Note importante
- Les certificats sont valides uniquement pour `zapwall.fr` (pas pour `www.zapwall.fr`)
- Pour ajouter `www.zapwall.fr`, il faut d'abord configurer le DNS pour pointer vers ce serveur, puis relancer certbot avec `-d zapwall.fr -d www.zapwall.fr`
#### Option 2 : acme.sh
```bash
ssh <USER>@<IP>
curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --issue -d zapwall.fr --standalone
```
### Renouvellement automatique des certificats
Certbot snap configure automatiquement le renouvellement. Pour vérifier :
```bash
# Vérifier le renouvellement automatique
sudo snap run certbot renew --dry-run
# Voir les tâches planifiées
sudo systemctl list-timers | grep certbot
```
#### Script de renouvellement personnalisé (si nécessaire)
Si vous devez copier les certificats dans le volume monté après renouvellement :
```bash
sudo nano /usr/local/bin/renew-zapwall-cert.sh
```
Contenu :
```bash
#!/bin/bash
set -e
DOMAIN="zapwall.fr"
MOUNTED_LETSENCRYPT="/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test"
NGINX_CONTAINER="lecoffre_nginx_test"
# Renouveler les certificats
sudo certbot renew --quiet
# Copier les nouveaux certificats dans le volume monté
sudo cp /etc/letsencrypt/archive/${DOMAIN}/* ${MOUNTED_LETSENCRYPT}/archive/${DOMAIN}/
# Recharger nginx
sudo docker exec ${NGINX_CONTAINER} nginx -s reload
```
Rendre exécutable et ajouter au cron :
```bash
sudo chmod +x /usr/local/bin/renew-zapwall-cert.sh
sudo crontab -e
# Ajouter : 0 3 * * * /usr/local/bin/renew-zapwall-cert.sh
```
---
## Script de déploiement
### `deploy.sh`
Script unique pour déployer ou mettre à jour l'application.
**Utilisation :**
```bash
# Déploiement depuis la branche main (par défaut)
./deploy.sh
# Déploiement depuis une autre branche
./deploy.sh develop
```
**Fonctionnalités :**
1. Vérifie et initialise le dépôt Git si nécessaire
2. Sauvegarde les modifications locales (stash)
3. Nettoie les fichiers non suivis
4. Met à jour depuis la branche spécifiée (par défaut: `main`)
5. Installe les dépendances (`npm ci`)
6. Construit l'application (`npm run build`)
7. Redémarre le service systemd
8. Vérifie que le service fonctionne correctement
**Exemple de sortie :**
```
=== Déploiement de zapwall.fr ===
Branche: main
1. Vérification du dépôt Git...
✓ Dépôt Git détecté
Branche actuelle: main
2. Récupération des dernières modifications...
3. Sauvegarde des modifications locales...
4. Nettoyage des fichiers non suivis...
5. Vérification de la branche main...
6. Mise à jour depuis la branche main...
7. Dernier commit: abc1234 Fix: correction du bug
8. Installation des dépendances...
9. Construction de l'application...
10. Redémarrage du service zapwall...
11. Vérification du service...
✓ Service actif
12. Vérification du port 3001...
✓ Port 3001 en écoute
=== Déploiement terminé avec succès ===
```
### Commandes utiles après déploiement
```bash
# Voir les logs en temps réel
ssh <USER>@<IP> 'sudo journalctl -u zapwall -f'
# Vérifier le statut du service
ssh <USER>@<IP> 'sudo systemctl status zapwall'
# Voir les stashes Git
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash list'
# Restaurer un stash
ssh <USER>@<IP> 'cd /var/www/zapwall.fr && git stash pop'
```
---
## Dépannage
### Le service ne démarre pas
```bash
# Voir les logs
ssh <USER>@<IP> 'sudo journalctl -u zapwall -n 50'
# Vérifier le statut
ssh <USER>@<IP> 'sudo systemctl status zapwall'
# Vérifier que le répertoire existe
ssh <USER>@<IP> 'ls -la /var/www/zapwall.fr'
# Vérifier que l'application est construite
ssh <USER>@<IP> 'ls -la /var/www/zapwall.fr/.next'
```
### Le port 3001 n'est pas en écoute
```bash
# Vérifier que le service est actif
ssh <USER>@<IP> 'sudo systemctl status zapwall'
# Redémarrer le service
ssh <USER>@<IP> 'sudo systemctl restart zapwall'
# Vérifier les processus
ssh <USER>@<IP> 'sudo ss -tuln | grep 3001'
```
### Nginx ne sert pas le bon site
Si nginx sert un autre site au lieu de zapwall.fr :
1. **Vérifier que la configuration zapwall.fr.conf est chargée** :
```bash
# Vérifier si zapwall.fr est dans la configuration chargée
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test nginx -T 2>&1 | grep "server_name zapwall.fr"'
# Si aucun résultat, vérifier que conf.d est inclus dans nginx.conf
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/nginx.conf | grep "include.*conf.d"'
```
2. **Si conf.d n'est pas inclus, corriger nginx.conf** :
```bash
# Le fichier est monté depuis l'hôte, modifier sur l'hôte
ssh <USER>@<IP> 'sudo tail -5 /home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf'
# Ajouter l'inclusion avant la fermeture du bloc http (avant })
# Ajouter ces lignes avant le dernier }
# # Include site configurations
# include /etc/nginx/conf.d/*.conf;
# Redémarrer le conteneur pour prendre en compte la modification
ssh <USER>@<IP> 'sudo docker restart lecoffre_nginx_test'
```
3. **Vérifier la configuration zapwall.fr.conf** :
```bash
# Vérifier la configuration
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
# Vérifier que proxy_pass pointe vers 172.17.0.1:3001
# Vérifier que server_name contient zapwall.fr
# Tester la configuration
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test nginx -t'
# Recharger nginx
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test nginx -s reload'
```
4. **Tester avec curl** :
```bash
# Simuler une requête pour zapwall.fr
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test curl -s -k -H "Host: zapwall.fr" https://localhost | head -5'
```
Voir aussi : `fixKnowledge/nginx-conf-d-not-loaded.md` pour plus de détails.
### Erreurs de build
Si le build échoue à cause d'erreurs ESLint :
```bash
# Vérifier que next.config.js ignore les erreurs ESLint
ssh <USER>@<IP> 'cat /var/www/zapwall.fr/next.config.js'
# Si nécessaire, copier la configuration depuis le dépôt local
cat next.config.js | ssh <USER>@<IP> 'cat > /var/www/zapwall.fr/next.config.js'
```
### Problèmes de certificats SSL
```bash
# Vérifier les certificats
ssh <USER>@<IP> 'sudo ls -la /etc/letsencrypt/live/zapwall.fr/'
# Vérifier dans le conteneur
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test ls -la /etc/letsencrypt/live/zapwall.fr/'
# Vérifier la configuration nginx
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test grep ssl_certificate /etc/nginx/conf.d/zapwall.fr.conf'
```
---
## Maintenance
### Commandes utiles
#### Voir les logs en temps réel
```bash
ssh <USER>@<IP> 'sudo journalctl -u zapwall -f'
```
#### Vérifier le statut du service
```bash
ssh <USER>@<IP> 'sudo systemctl status zapwall'
```
#### Redémarrer le service
```bash
ssh <USER>@<IP> 'sudo systemctl restart zapwall'
```
#### Vérifier les ports
```bash
# Port application
ssh <USER>@<IP> 'sudo ss -tuln | grep 3001'
# Ports HTTP/HTTPS
ssh <USER>@<IP> 'sudo ss -tuln | grep -E "(80|443)"'
```
#### Vérifier la configuration nginx
```bash
# Tester la configuration
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test nginx -t'
# Voir la configuration
ssh <USER>@<IP> 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
```
#### Vérifier le conteneur Docker
```bash
# Statut du conteneur
ssh <USER>@<IP> 'sudo docker ps | grep lecoffre_nginx_test'
# Logs du conteneur
ssh <USER>@<IP> 'sudo docker logs lecoffre_nginx_test --tail 50'
```
### Tâches de maintenance régulières
1. **Mise à jour du code** : Utiliser `update-remote-git.sh` régulièrement
2. **Renouvellement des certificats** : Automatisé via cron (à configurer)
3. **Mise à jour des dépendances** : `npm audit` et `npm update` si nécessaire
4. **Nettoyage des logs** : Rotation automatique via systemd/journald
5. **Surveillance** : Vérifier régulièrement les logs et le statut du service
### Sauvegarde
Les fichiers importants à sauvegarder :
- `/var/www/zapwall.fr/` : Code source et build
- `/etc/systemd/system/zapwall.service` : Configuration du service
- `/etc/letsencrypt/live/zapwall.fr/` : Certificats SSL (si Let's Encrypt)
- Configuration nginx dans le conteneur Docker
---
## Références
### Documentation liée
- `README-DEPLOYMENT.md` : Guide de déploiement détaillé
- `RESUME-DEPLOIEMENT.md` : Résumé du déploiement
- `update-summary.md` : Résumé des mises à jour
### Liens utiles
- Dépôt Git : `https://git.4nkweb.com/4nk/story-research-zapwall.git`
- Documentation Next.js : https://nextjs.org/docs
- Documentation Let's Encrypt : https://letsencrypt.org/docs/
- Documentation systemd : https://www.freedesktop.org/software/systemd/man/
---
## Support
En cas de problème :
1. Consulter les logs : `sudo journalctl -u zapwall -n 100`
2. Vérifier le statut : `sudo systemctl status zapwall`
3. Vérifier la configuration : Utiliser les scripts de vérification
4. Consulter cette documentation : Section [Dépannage](#dépannage)
---
*Dernière mise à jour : 2025-12-28*

View File

@ -234,7 +234,7 @@ Oui, vous pouvez rechercher par titre, aperçu ou contenu. Vous pouvez égalemen
### Quel relay Nostr est utilisé ?
Par défaut, l'application utilise `wss://relay.damus.io`. Vous pouvez configurer un autre relay via la variable d'environnement `NEXT_PUBLIC_NOSTR_RELAY_URL`.
Par défaut, l'application utilise `wss://relay.damus.io`. La configuration des relais est stockée dans IndexedDB (stockage local du navigateur) et peut être personnalisée via les paramètres de l'application. L'application supporte plusieurs relais avec un système de priorité.
### Les données sont-elles stockées sur un serveur ?
@ -287,7 +287,7 @@ Le contenu peut être perdu. Vous devrez peut-être payer à nouveau pour déblo
### Puis-je contacter le support ?
Pour l'instant, il n'y a pas de support officiel. Consultez la documentation ou créez une issue sur le dépôt GitHub du projet.
Pour l'instant, il n'y a pas de support officiel. Consultez la documentation ou créez une issue sur le [dépôt Gitea du projet](https://git.4nkweb.com/4nk/story-research-zapwall/issues).
---

66
docs/quick-reference.md Normal file
View File

@ -0,0 +1,66 @@
# Guide de référence rapide - zapwall.fr
## 🚀 Commandes essentielles
### Mise à jour du site
```bash
# Méthode recommandée : Script automatique
./deploy.sh
# Méthode manuelle
ssh debian@92.243.27.35
cd /var/www/zapwall.fr
git stash
git pull origin main
npm ci
npm run build
sudo systemctl restart zapwall
```
### Vérification du statut
```bash
# Service
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
# Logs en temps réel
ssh debian@92.243.27.35 'sudo journalctl -u zapwall -f'
# Port 3001
ssh debian@92.243.27.35 'sudo ss -tuln | grep 3001'
```
### Redémarrage
```bash
ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
```
### Configuration nginx
```bash
# Voir la configuration
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
# Tester la configuration
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -t'
# Recharger nginx
ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -s reload'
```
## 📍 Informations importantes
- **Serveur** : `92.243.27.35`
- **Domaine** : `zapwall.fr`
- **Répertoire** : `/var/www/zapwall.fr`
- **Port** : `3001`
- **Service** : `zapwall.service`
- **Nginx** : Conteneur `lecoffre_nginx_test`
## 🔗 Liens rapides
- Documentation complète : `docs/deployment.md`
- Référence des scripts : `docs/scripts-reference.md`
- Guide de déploiement : `README-DEPLOYMENT.md`

View File

@ -1,132 +0,0 @@
# Configuration de l'API Rizful.com
**Auteur** : Équipe 4NK
## Obtention de la clé API Rizful
### Étape 1 : Créer un compte Rizful
1. Accédez à [Rizful.com](https://rizful.com/)
2. Cliquez sur "Sign Up" ou "Créer un compte"
3. Remplissez le formulaire d'inscription
4. Vérifiez votre email si nécessaire
### Étape 2 : Accéder aux paramètres API
1. Connectez-vous à votre compte Rizful
2. Accédez à la section **"Settings"** ou **"API"** dans votre tableau de bord
3. Cherchez la section **"API Keys"** ou **"Clés API"**
### Étape 3 : Générer une clé API
1. Dans la section API Keys, cliquez sur **"Generate New API Key"** ou **"Créer une nouvelle clé"**
2. Donnez un nom à votre clé (ex: "Nostr Paywall Production" ou "Nostr Paywall Development")
3. Copiez la clé API générée **immédiatement** - elle ne sera affichée qu'une seule fois
**⚠️ Important** : Stockez votre clé API en sécurité. Si vous la perdez, vous devrez en générer une nouvelle.
### Étape 4 : Configurer l'adresse Lightning (optionnel mais recommandé)
1. Dans les paramètres de votre compte, accédez à **"Lightning Address"** ou **"Adresse Lightning"**
2. Configurez une adresse Lightning personnalisée (ex: `votre_nom@rizful.com`)
3. Cette adresse facilitera la réception des paiements
### Étape 5 : Activer l'authentification à deux facteurs (2FA)
Pour la sécurité de votre compte :
1. Accédez aux paramètres de sécurité
2. Activez l'authentification à deux facteurs (2FA)
3. Utilisez une application d'authentification comme Google Authenticator ou Microsoft Authenticator
**Note** : La 2FA est obligatoire pour les comptes détenant plus de 100 000 satoshis.
## Configuration dans le projet
### Variables d'environnement
Une fois votre clé API obtenue, configurez-la dans votre environnement :
#### Développement local (`.env.local`)
Créez ou modifiez le fichier `.env.local` à la racine du projet :
```env
# Rizful API Configuration (SERVER-SIDE ONLY)
RIZFUL_API_KEY=votre_clé_api_ici
RIZFUL_API_URL=https://api.rizful.com
# Variables publiques (client-side)
NEXT_PUBLIC_NOSTR_RELAY_URL=wss://relay.damus.io
```
**⚠️ Important** :
- Ne mettez **PAS** `NEXT_PUBLIC_` devant `RIZFUL_API_KEY`
- Cette clé doit rester côté serveur uniquement
- Ne commitez **jamais** le fichier `.env.local` dans Git (il est déjà dans `.gitignore`)
#### Production (Vercel, Netlify, etc.)
1. Accédez aux paramètres de votre projet sur votre plateforme d'hébergement
2. Allez dans la section **"Environment Variables"** ou **"Variables d'environnement"**
3. Ajoutez la variable :
- **Name** : `RIZFUL_API_KEY`
- **Value** : votre clé API Rizful
- **Environment** : Production (et/ou Preview si nécessaire)
4. Ajoutez également `RIZFUL_API_URL` si vous utilisez une URL différente
### Vérification de la configuration
Pour vérifier que votre clé API est correctement configurée :
1. Lancez le serveur de développement : `npm run dev`
2. Essayez de créer une facture Lightning via l'interface
3. Vérifiez la console du navigateur et les logs du serveur pour les erreurs
Si vous voyez une erreur "RIZFUL_API_KEY not configured", vérifiez :
- Que la variable est bien définie dans `.env.local` (développement)
- Que la variable est bien configurée dans votre plateforme d'hébergement (production)
- Que vous avez redémarré le serveur après avoir ajouté la variable
## Sécurité
### Bonnes pratiques
1. **Ne partagez jamais votre clé API**
2. **Ne commitez jamais votre clé API** dans le dépôt Git
3. **Utilisez des clés différentes** pour le développement et la production
4. **Régénérez votre clé** si elle est compromise
5. **Activez la 2FA** sur votre compte Rizful
### Limitation d'accès (si disponible)
Dans les paramètres de votre compte Rizful, vous pourriez pouvoir :
- Limiter l'utilisation de la clé API par IP
- Limiter l'utilisation par domaine/origine
- Révoquer et régénérer des clés
Consultez la documentation Rizful pour ces fonctionnalités.
## Documentation Rizful
Pour plus d'informations :
- Site web : [https://rizful.com/](https://rizful.com/)
- Documentation API : Vérifiez la section "API" ou "Documentation" sur le site
- Support : Contactez le support Rizful si vous avez des questions
## Dépannage
### Erreur : "RIZFUL_API_KEY not configured"
**Solution** : Vérifiez que la variable d'environnement est bien définie et que le serveur a été redémarré.
### Erreur : "Failed to create invoice"
**Solutions possibles** :
- Vérifiez que votre clé API est valide
- Vérifiez que votre compte Rizful est actif
- Vérifiez les logs serveur pour plus de détails
- Contactez le support Rizful si le problème persiste
### Erreur : "Unauthorized" ou "401"
**Solution** : Votre clé API est invalide ou expirée. Régénérez une nouvelle clé dans votre compte Rizful.

372
docs/scripts-reference.md Normal file
View File

@ -0,0 +1,372 @@
# Référence des scripts de déploiement
## 📋 Vue d'ensemble
Ce document décrit tous les scripts disponibles pour le déploiement et la maintenance de zapwall.fr.
---
## Scripts de déploiement
### `deploy.sh`
**Description** : Déploiement initial complet avec vérifications approfondies.
**Fonctionnalités** :
- Vérification des ports (3001, 80, 443)
- Détection automatique de Docker/nginx
- Vérification des configurations existantes
- Transfert des fichiers
- Installation des dépendances
- Construction de l'application
- Configuration nginx (Docker ou système)
- Création du service systemd
- Vérifications post-déploiement
**Utilisation** :
```bash
./deploy.sh
```
**Options** :
- Mode non-interactif : `CI=true ./deploy.sh`
---
### `update-remote-git.sh`
**Description** : Mise à jour du site via Git directement sur le serveur.
**Fonctionnalités** :
- Initialisation Git si nécessaire
- Stash des modifications locales (y compris fichiers non suivis)
- Pull depuis la branche spécifiée
- Installation des dépendances
- Construction de l'application
- Redémarrage du service
- Vérifications complètes
**Utilisation** :
```bash
# Utilise la branche actuelle ou main par défaut
./update-remote-git.sh
# Spécifier une branche
./update-remote-git.sh main
```
**Paramètres** :
- `$1` : Nom de la branche (optionnel, défaut: main)
---
### `update-from-git.sh`
**Description** : Mise à jour depuis le dépôt Git local.
**Fonctionnalités** :
- Transfert des fichiers depuis le dépôt local
- Installation des dépendances
- Construction de l'application
- Redémarrage du service
**Utilisation** :
```bash
./update-from-git.sh [branche]
```
---
### `finish-deploy.sh`
**Description** : Finalisation du déploiement (configuration nginx + service).
**Fonctionnalités** :
- Configuration nginx Docker
- Création du service systemd
- Démarrage et vérification
**Utilisation** :
```bash
./finish-deploy.sh
```
---
## Scripts de vérification
### `check-deploy.sh`
**Description** : Vérification préalable avant déploiement.
**Vérifications** :
- Connexion SSH
- Ports utilisés
- Port 3001 libre
- État de nginx
- Configurations existantes
- Services systemd
- Test de configuration nginx
**Utilisation** :
```bash
./check-deploy.sh
```
---
### `check-deployment-status.sh`
**Description** : État complet du déploiement.
**Informations affichées** :
- Certificats SSL
- Configuration nginx
- État du service
- Ports en écoute
- Conteneur Docker
**Utilisation** :
```bash
./check-deployment-status.sh
```
---
### `check-nginx-config.sh`
**Description** : Vérification de la configuration nginx.
**Affichage** :
- Configuration principale
- Toutes les configurations dans conf.d
- Configuration zapwall.fr
- Configuration default.conf
**Utilisation** :
```bash
./check-nginx-config.sh
```
---
### `check-git-repo.sh`
**Description** : Vérification du dépôt Git sur le serveur.
**Informations** :
- Présence du dépôt Git
- Branche actuelle
- Dernier commit
- Remote configuré
**Utilisation** :
```bash
./check-git-repo.sh
```
---
### `final-status.sh`
**Description** : Résumé de l'état final du déploiement.
**Utilisation** :
```bash
./final-status.sh
```
---
## Scripts de configuration HTTPS
### `setup-https-autosigned.sh`
**Description** : Configuration HTTPS avec certificats auto-signés.
**Fonctionnalités** :
- Génération de certificats auto-signés
- Configuration nginx avec HTTPS
- Redirection HTTP → HTTPS
- Headers de sécurité
**Utilisation** :
```bash
./setup-https-autosigned.sh
```
---
### `deploy-letsencrypt.sh`
**Description** : Déploiement des certificats Let's Encrypt (mode standalone).
**Fonctionnalités** :
- Arrêt du conteneur nginx
- Obtention des certificats
- Copie dans le conteneur
- Mise à jour de la configuration
**Utilisation** :
```bash
./deploy-letsencrypt.sh
```
---
### `deploy-letsencrypt-webroot.sh`
**Description** : Déploiement Let's Encrypt en mode webroot.
**Fonctionnalités** :
- Obtention des certificats sans arrêter nginx
- Utilisation du challenge webroot
**Utilisation** :
```bash
./deploy-letsencrypt-webroot.sh
```
---
### `generate-certs.sh`
**Description** : Génération de certificats auto-signés.
**Utilisation** :
```bash
./generate-certs.sh
```
---
## Scripts utilitaires
### `open-firewall-ports.sh`
**Description** : Ouverture des ports 80 et 443 dans le firewall.
**Fonctionnalités** :
- Détection du type de firewall (UFW ou iptables)
- Ouverture des ports nécessaires
**Utilisation** :
```bash
./open-firewall-ports.sh
```
---
### `fix-nginx-config.sh`
**Description** : Correction de la configuration nginx et du service.
**Fonctionnalités** :
- Vérification du répertoire du service
- Vérification de la construction
- Redémarrage du service
**Utilisation** :
```bash
./fix-nginx-config.sh
```
---
### `upgrade-python-certbot.sh`
**Description** : Mise à jour de Python et Certbot.
**Fonctionnalités** :
- Installation de Python 3.12 si disponible
- Réinstallation de Certbot
**Utilisation** :
```bash
./upgrade-python-certbot.sh
```
---
## Scripts de diagnostic
### `check-docker.sh`
**Description** : Liste des conteneurs Docker.
**Utilisation** :
```bash
./check-docker.sh
```
---
### `check-ports.sh`
**Description** : Vérification des ports utilisés.
**Utilisation** :
```bash
./check-ports.sh
```
---
### `check-nginx-docker.sh`
**Description** : Configuration du nginx Docker.
**Utilisation** :
```bash
./check-nginx-docker.sh
```
---
## Variables de configuration
Tous les scripts utilisent ces variables (modifiables dans chaque script) :
```bash
SERVER="debian@92.243.27.35"
APP_NAME="zapwall"
DOMAIN="zapwall.fr"
APP_PORT=3001
APP_DIR="/var/www/zapwall.fr"
NGINX_CONTAINER="lecoffre_nginx_test"
GIT_REPO="https://git.4nkweb.com/4nk/story-research-zapwall.git"
```
---
## Ordre d'exécution recommandé
### Déploiement initial
1. `check-deploy.sh` - Vérification préalable
2. `deploy.sh` - Déploiement complet
3. `setup-https-autosigned.sh` - Configuration HTTPS
4. `check-deployment-status.sh` - Vérification finale
### Mise à jour régulière
1. `update-remote-git.sh` - Mise à jour depuis Git
### Dépannage
1. `check-deployment-status.sh` - État général
2. `check-nginx-config.sh` - Configuration nginx
3. `fix-nginx-config.sh` - Correction si nécessaire
---
## Notes importantes
- Tous les scripts nécessitent un accès SSH au serveur
- Les scripts utilisent `set -e` pour arrêter en cas d'erreur
- Les modifications sont sauvegardées (stash Git) avant les mises à jour
- Les scripts vérifient l'état avant de modifier
---
*Dernière mise à jour : 2025-12-28*

View File

@ -0,0 +1,170 @@
# Open Source Setup
**Date**: December 2024
**Auteur**: Équipe 4NK
## Objectif
Mise en place complète du projet en open source avec documentation des contributions, code de conduite, politique de sécurité et templates pour issues et pull requests.
## Motivations
- Faciliter les contributions externes au projet
- Standardiser le processus de contribution
- Assurer la sécurité et la qualité du code
- Créer un environnement accueillant pour les contributeurs
- Documenter les bonnes pratiques et les guidelines
## Modifications
### Fichiers créés
1. **LICENSE** : Licence MIT pour le projet
- Permet l'utilisation, modification et distribution libre
- Standard pour les projets open source
2. **README.md** : Amélioration avec sections open source
- Badges (License, TypeScript, Next.js)
- Table of contents
- Section Contributing avec workflow
- Section Documentation
- Section License
3. **CONTRIBUTING.md** : Guide complet de contribution
- Code of Conduct
- Getting Started
- Development Setup
- Coding Guidelines détaillées
- Workflow complet (branches, commits, PRs)
- Commit Guidelines avec format structuré
- Checklist avant soumission
- What Not to Do
4. **CODE_OF_CONDUCT.md** : Code de conduite
- Basé sur Contributor Covenant v2.0
- Standards de comportement
- Processus d'enforcement
- Guidelines d'impact communautaire
5. **SECURITY.md** : Politique de sécurité
- Processus de reporting des vulnérabilités
- Timeline de réponse
- Best practices de sécurité
- Considérations de sécurité spécifiques au projet
- Checklist de sécurité pour les PRs
6. **.gitea/ISSUE_TEMPLATE/** : Templates pour issues (Gitea)
- `bug_report.md` : Template pour rapports de bugs
- `feature_request.md` : Template pour demandes de fonctionnalités
- `question.md` : Template pour questions
7. **.gitea/PULL_REQUEST_TEMPLATE.md** : Template pour pull requests (Gitea)
- Format structuré avec sections Motivations, Root causes, Correctifs, Evolutions, Pages affectées
- Checklist de validation
- Sections de testing et documentation
## Structure des templates
### Issue Templates
- **Bug Report** : Sections pour description, étapes de reproduction, environnement, erreurs console
- **Feature Request** : Sections pour description, use cases, considérations techniques
- **Question** : Format simple pour questions et contexte
### Pull Request Template
- Format aligné avec les guidelines de commit du projet
- Sections structurées (Motivations, Root causes, Correctifs, Evolutions, Pages affectées)
- Checklist complète de validation
- Sections pour testing et documentation
## Guidelines de contribution
### Principes fondamentaux
- Pas de fallbacks ou échecs silencieux
- Pas d'analytics
- Pas de tests ad-hoc (sauf demande explicite)
- TypeScript strict (pas de `any`, pas de `ts-ignore`)
- Gestion d'erreurs explicite
- Accessibilité (ARIA, clavier, contraste)
### Workflow standardisé
1. Fork du repository
2. Création de branche feature/fix
3. Développement suivant les guidelines
4. Lint et type-check
5. Commit avec format structuré
6. Pull Request avec template
7. Review et merge
### Format de commit
Commits exhaustifs et synthétiques avec :
- **Motivations**
- **Root causes**
- **Correctifs**
- **Evolutions**
- **Pages affectées**
## Sécurité
### Processus de reporting
- Utilisation d'issues privées sur Gitea (préfixées [SECURITY])
- Reporting privé (pas d'issues publiques)
- Timeline de réponse définie
- Crédit des chercheurs en sécurité
### Considérations spécifiques
- Authentification Nostr (NIP-07 via Alby)
- Paiements Lightning (WebLN)
- Stockage chiffré (IndexedDB avec AES-GCM)
- Pas de secrets en clair
## Accessibilité
- Respect ARIA
- Navigation clavier
- Contraste WCAG
- Pas de régressions
## Documentation
- Documentation des fixes dans `fixKnowledge/`
- Documentation des features dans `features/`
- Format structuré avec sections claires
- Attribution (Équipe 4NK)
## Modalités de déploiement
Aucun déploiement nécessaire. Les fichiers sont directement dans le repository et seront visibles sur Gitea lors du push.
**Repository Gitea** : https://git.4nkweb.com/4nk/story-research-zapwall
## Modalités d'analyse
### Vérifications à effectuer
1. **LICENSE** : Vérifier que la licence MIT est appropriée
2. **README.md** : Vérifier les liens et badges
3. **CONTRIBUTING.md** : Vérifier la cohérence avec les règles du projet et les URLs Gitea
4. **Templates** : Tester la création d'issues et PRs sur Gitea
5. **SECURITY.md** : Vérifier les références Gitea
### Points d'attention
- Les URLs dans CONTRIBUTING.md pointent vers git.4nkweb.com
- Les templates sont dans `.gitea/` pour compatibilité Gitea
- Les références GitHub ont été remplacées par Gitea
- Le repository est : https://git.4nkweb.com/4nk/story-research-zapwall
## Prochaines étapes
1. Tester la création d'issues et PRs sur Gitea avec les templates
2. Vérifier que Gitea reconnaît les templates dans `.gitea/`
3. Ajouter des labels Gitea si nécessaire (bug, enhancement, question, etc.)
4. Configurer les branch protection rules sur Gitea si nécessaire
5. Vérifier que les permissions du repository permettent le fork et les contributions

View File

@ -0,0 +1,60 @@
# Configuration des certificats Let's Encrypt pour zapwall.fr
## Date
2025-12-28
## Problème
Le site https://zapwall.fr/ utilisait des certificats auto-signés, ce qui provoquait des avertissements de sécurité dans les navigateurs.
## Solution
Configuration de certificats Let's Encrypt valides via certbot snap.
## Motivations
- Éliminer les avertissements de sécurité dans les navigateurs
- Obtenir des certificats SSL valides et reconnus
- Utiliser certbot snap pour éviter le bug avec certbot et Python 3.11
## Root causes
- Certificats auto-signés utilisés initialement
- Certbot classique présentait un bug `AttributeError: can't set attribute` avec Python 3.11
- Solution : utiliser certbot via snap qui utilise son propre environnement Python
## Correctifs
1. Installation de certbot via snap : `sudo snap install certbot --classic`
2. Obtention des certificats en mode standalone (nginx arrêté) : `sudo certbot certonly --standalone -d zapwall.fr`
3. Copie des certificats dans le volume monté Docker pour nginx
4. Mise à jour de la configuration nginx pour utiliser les certificats Let's Encrypt
## Modifications
- **Certificats obtenus** : `/etc/letsencrypt/live/zapwall.fr/` (sur l'hôte)
- **Volume monté** : `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/``/etc/letsencrypt` (dans le conteneur, en lecture seule)
- **Configuration nginx** :
- `ssl_certificate /etc/letsencrypt/live/zapwall.fr/fullchain.pem;`
- `ssl_certificate_key /etc/letsencrypt/live/zapwall.fr/privkey.pem;`
## Modalités de déploiement
1. Installer snap si nécessaire : `sudo apt-get install -y snapd`
2. Installer certbot via snap : `sudo snap install certbot --classic`
3. Arrêter nginx : `sudo docker stop lecoffre_nginx_test`
4. Obtenir les certificats : `sudo certbot certonly --standalone -d zapwall.fr --non-interactive --agree-tos --email admin@zapwall.fr`
5. Copier les certificats dans le volume monté :
- Créer la structure : `sudo mkdir -p /home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/{live,archive}/zapwall.fr`
- Copier les fichiers : `sudo cp /etc/letsencrypt/archive/zapwall.fr/* /home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/archive/zapwall.fr/`
- Créer les liens symboliques dans `live/zapwall.fr/`
6. Redémarrer nginx : `sudo docker start lecoffre_nginx_test`
7. Mettre à jour la configuration nginx pour pointer vers les certificats Let's Encrypt
8. Recharger nginx : `sudo docker exec lecoffre_nginx_test nginx -s reload`
## Modalités d'analyse
Pour vérifier l'état des certificats :
1. Vérifier les certificats sur l'hôte : `sudo ls -la /etc/letsencrypt/live/zapwall.fr/`
2. Vérifier dans le conteneur : `sudo docker exec lecoffre_nginx_test ls -la /etc/letsencrypt/live/zapwall.fr/`
3. Vérifier la configuration nginx : `sudo docker exec lecoffre_nginx_test grep ssl_certificate /etc/nginx/conf.d/zapwall.fr.conf`
4. Tester la connexion SSL : `openssl s_client -connect zapwall.fr:443 -servername zapwall.fr`
## Notes importantes
- Les certificats sont valides uniquement pour `zapwall.fr` (pas pour `www.zapwall.fr`)
- Pour ajouter `www.zapwall.fr`, configurer d'abord le DNS, puis relancer certbot avec `-d zapwall.fr -d www.zapwall.fr`
- Les certificats expirent après 90 jours, mais certbot snap configure automatiquement le renouvellement
- Le volume `/etc/letsencrypt` est monté en lecture seule dans le conteneur, donc les certificats doivent être copiés sur l'hôte dans le répertoire monté
- Certbot snap utilise son propre environnement Python, évitant le bug avec Python 3.11

View File

@ -0,0 +1,57 @@
# Problème : nginx ne charge pas les configurations dans conf.d
## Date
2025-12-28
## Problème
Le site https://zapwall.fr/ pointait vers le mauvais site (LEcoffre au lieu de Zapwall) malgré une configuration correcte dans `/etc/nginx/conf.d/zapwall.fr.conf`.
## Symptômes
- https://zapwall.fr/ servait le site LEcoffre (test-lecoffreio.4nkweb.com)
- La configuration `zapwall.fr.conf` existait et était correcte
- Le service zapwall fonctionnait correctement sur le port 3001
- Nginx ne chargeait pas les configurations dans `conf.d/`
## Root cause
La configuration principale nginx (`/etc/nginx/nginx.conf` dans le conteneur Docker `lecoffre_nginx_test`) ne contenait pas la directive `include /etc/nginx/conf.d/*.conf;` pour charger les configurations des sites dans le répertoire `conf.d/`.
Le fichier nginx.conf était monté depuis l'hôte (`/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf`) et ne chargeait que la configuration pour `test-lecoffreio.4nkweb.com`.
## Impact
- Le site zapwall.fr n'était pas accessible correctement
- Les utilisateurs voyaient le mauvais site
- La configuration zapwall.fr.conf était ignorée par nginx
## Correctifs
1. Ajout de la directive `include /etc/nginx/conf.d/*.conf;` dans le bloc `http` de nginx.conf, avant la fermeture du bloc
2. Modification du fichier sur l'hôte : `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf`
3. Redémarrage du conteneur nginx pour prendre en compte la modification
## Modifications
- **Fichier modifié** : `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf` (sur l'hôte)
- **Ajout** :
```nginx
# Include site configurations
include /etc/nginx/conf.d/*.conf;
```
Avant la fermeture du bloc `http` (avant `}`)
## Modalités de déploiement
1. Arrêter le conteneur nginx : `sudo docker stop lecoffre_nginx_test`
2. Modifier le fichier sur l'hôte : `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf`
3. Ajouter l'inclusion avant la fermeture du bloc `http`
4. Démarrer le conteneur : `sudo docker start lecoffre_nginx_test`
5. Vérifier la configuration : `sudo docker exec lecoffre_nginx_test nginx -t`
6. Vérifier que zapwall.fr est chargé : `sudo docker exec lecoffre_nginx_test nginx -T | grep "server_name zapwall.fr"`
## Modalités d'analyse
Pour vérifier si le problème existe :
1. Vérifier si l'inclusion existe : `sudo docker exec lecoffre_nginx_test cat /etc/nginx/nginx.conf | grep "include.*conf.d"`
2. Vérifier si zapwall.fr est chargé : `sudo docker exec lecoffre_nginx_test nginx -T | grep "server_name zapwall.fr"`
3. Tester avec curl : `sudo docker exec lecoffre_nginx_test curl -s -k -H "Host: zapwall.fr" https://localhost | head -5`
## Notes
- Le fichier nginx.conf est monté en lecture seule depuis l'hôte dans le conteneur Docker
- Les modifications doivent être faites sur l'hôte, pas dans le conteneur
- Le conteneur doit être redémarré pour prendre en compte les modifications du fichier monté
- L'inclusion doit être dans le bloc `http`, pas après sa fermeture

View File

@ -6,12 +6,16 @@ export function useNostrAuth() {
const [state, setState] = useState<NostrConnectState>(nostrAuthService.getState())
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [accountExists, setAccountExists] = useState<boolean | null>(null)
useEffect(() => {
const unsubscribe = nostrAuthService.subscribe((newState) => {
setState(newState)
})
// Check if account exists on mount
nostrAuthService.accountExists().then(setAccountExists).catch(() => setAccountExists(false))
return unsubscribe
}, [])
@ -44,5 +48,7 @@ export function useNostrAuth() {
error,
connect,
disconnect,
accountExists,
isUnlocked: nostrAuthService.isUnlocked(),
}
}

View File

@ -58,6 +58,21 @@ function arrayBufferToHex(buffer: ArrayBuffer): string {
* Encrypt article content with AES-GCM
* Returns encrypted content, IV, and the encryption key
*/
async function prepareEncryptionKey(key: string): Promise<CryptoKey> {
const keyBuffer = hexToArrayBuffer(key)
return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt'])
}
function prepareIV(iv: Uint8Array): { view: Uint8Array; buffer: ArrayBuffer } {
const ivBuffer = new ArrayBuffer(iv.byteLength)
const ivView = new Uint8Array(ivBuffer)
// Copy bytes from original IV
for (let i = 0; i < iv.length; i++) {
ivView[i] = iv[i] ?? 0
}
return { view: ivView, buffer: ivBuffer }
}
export async function encryptArticleContent(content: string): Promise<{
encryptedContent: string
key: string
@ -65,39 +80,21 @@ export async function encryptArticleContent(content: string): Promise<{
}> {
const key = generateEncryptionKey()
const iv = generateIV()
const keyBuffer = hexToArrayBuffer(key)
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'AES-GCM' },
false,
['encrypt']
)
const cryptoKey = await prepareEncryptionKey(key)
const encoder = new TextEncoder()
const encodedContent = encoder.encode(content)
const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength)
const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength)
ivView.set(iv)
const { view: ivView, buffer: ivBuffer } = prepareIV(iv)
const encryptedBuffer = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: ivView,
},
{ name: 'AES-GCM', iv: ivView as Uint8Array<ArrayBuffer> },
cryptoKey,
encodedContent
)
const encryptedContent = arrayBufferToHex(encryptedBuffer)
const ivHex = arrayBufferToHex(ivView.buffer)
return {
encryptedContent,
encryptedContent: arrayBufferToHex(encryptedBuffer),
key,
iv: ivHex,
iv: arrayBufferToHex(ivBuffer),
}
}

View File

@ -163,6 +163,48 @@ function buildReviewEvent(
}
}
function buildUpdateTags(draft: ArticleDraft, originalArticleId: string, newCategory: 'sciencefiction' | 'research') {
const updateTags = buildTags({
type: 'publication',
category: newCategory,
id: '', // Will be set to event.id after publication
paywall: true,
title: draft.title,
preview: draft.preview,
zapAmount: draft.zapAmount,
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
})
updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
return updateTags
}
async function publishUpdate(
draft: ArticleDraft,
authorPubkey: string,
originalArticleId: string
): Promise<ArticleUpdateResult> {
const category = draft.category
requireCategory(category)
const presentationId = await ensurePresentation(authorPubkey)
const invoice = await createArticleInvoice(draft)
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
const updateTags = buildUpdateTags(draft, originalArticleId, newCategory)
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
if (!publishedEvent) {
return updateFailure(originalArticleId, 'Failed to publish article update')
}
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
return {
articleId: publishedEvent.id,
previewEventId: publishedEvent.id,
invoice,
success: true,
originalArticleId,
}
}
export async function publishArticleUpdate(
originalArticleId: string,
draft: ArticleDraft,
@ -171,41 +213,7 @@ export async function publishArticleUpdate(
): Promise<ArticleUpdateResult> {
try {
ensureKeys(authorPubkey, authorPrivateKey)
const category = draft.category
requireCategory(category)
const presentationId = await ensurePresentation(authorPubkey)
const invoice = await createArticleInvoice(draft)
// Map category to new system
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
// Build tags using new system (update event references original)
const updateTags = buildTags({
type: 'publication',
category: newCategory,
id: '', // Will be set to event.id after publication
paywall: true,
title: draft.title,
preview: draft.preview,
zapAmount: draft.zapAmount,
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
})
// Add reference to original article
updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
if (!publishedEvent) {
return updateFailure(originalArticleId, 'Failed to publish article update')
}
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
return {
articleId: publishedEvent.id,
previewEventId: publishedEvent.id,
invoice,
success: true,
originalArticleId,
}
return await publishUpdate(draft, authorPubkey, originalArticleId)
} catch (error) {
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
}

View File

@ -1,48 +1,13 @@
import { nostrService } from './nostr'
import type { AlbyInvoice } from '@/types/alby'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { MediaRef } from '@/types/nostr'
import {
storePrivateContent,
getStoredPrivateContent,
getStoredInvoice,
removeStoredPrivateContent,
} from './articleStorage'
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
import type { AlbyInvoice } from '@/types/alby'
import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage'
import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers'
import {
encryptArticleContent,
encryptDecryptionKey,
} from './articleEncryption'
import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation'
import { buildFailure, encryptAndPublish } from './articlePublisherPublish'
export interface ArticleDraft {
title: string
preview: string
content: string // Full content that will be sent as private message after payment
zapAmount: number
category?: 'science-fiction' | 'scientific-research'
seriesId?: string
bannerUrl?: string
media?: MediaRef[]
}
export interface AuthorPresentationDraft {
title: string
preview: string
content: string
presentation: string
contentDescription: string
mainnetAddress: string
pictureUrl?: string | undefined
}
export interface PublishedArticle {
articleId: string
previewEventId: string
invoice?: AlbyInvoice // Invoice created by author (required if success)
success: boolean
error?: string
}
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
/**
* Service for publishing articles on Nostr
@ -51,61 +16,39 @@ export interface PublishedArticle {
export class ArticlePublisher {
// Removed unused siteTag - using new tag system instead
private buildFailure(error?: string): PublishedArticle {
const base: PublishedArticle = {
articleId: '',
previewEventId: '',
success: false,
}
return error ? { ...base, error } : base
}
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
nostrService.setPublicKey(authorPubkey)
if (authorPrivateKey) {
nostrService.setPrivateKey(authorPrivateKey)
return { success: true }
private async validatePublishRequest(
draft: ArticleDraft,
authorPubkey: string,
authorPrivateKey?: string
): Promise<PublishValidationResult> {
const keySetup = prepareAuthorKeys(authorPubkey, authorPrivateKey)
if (!keySetup.success) {
return { success: false, error: keySetup.error ?? 'Key setup failed' }
}
const existingPrivateKey = nostrService.getPrivateKey()
if (!existingPrivateKey) {
const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
if (!authorPrivateKeyForEncryption) {
return { success: false, error: 'Private key required for encryption' }
}
const presentation = await this.getAuthorPresentation(authorPubkey)
if (!presentation) {
return { success: false, error: 'Vous devez créer un article de présentation avant de publier des articles.' }
}
if (!isValidCategory(draft.category)) {
return { success: false, error: 'Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).' }
}
const expectedAmount = 800
if (draft.zapAmount !== expectedAmount) {
return {
success: false,
error:
'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
error: `Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
}
}
return { success: true }
}
private isValidCategory(category?: ArticleDraft['category']): category is NonNullable<ArticleDraft['category']> {
return category === 'science-fiction' || category === 'scientific-research'
}
private async publishPreview(
draft: ArticleDraft,
invoice: AlbyInvoice,
presentationId: string,
extraTags?: string[][],
encryptedContent?: string,
encryptedKey?: string
): Promise<import('nostr-tools').Event | null> {
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null
}
private 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
return { success: true, authorPrivateKeyForEncryption, category: draft.category }
}
/**
@ -119,67 +62,20 @@ export class ArticlePublisher {
authorPrivateKey?: string
): Promise<PublishedArticle> {
try {
const keySetup = this.prepareAuthorKeys(authorPubkey, authorPrivateKey)
if (!keySetup.success) {
return this.buildFailure(keySetup.error)
}
const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
if (!authorPrivateKeyForEncryption) {
return this.buildFailure('Private key required for encryption')
const validation = await this.validatePublishRequest(draft, authorPubkey, authorPrivateKey)
if (!validation.success) {
return buildFailure(validation.error)
}
const presentation = await this.getAuthorPresentation(authorPubkey)
if (!presentation) {
return this.buildFailure('Vous devez créer un article de présentation avant de publier des articles.')
return buildFailure('Presentation not found')
}
if (!this.isValidCategory(draft.category)) {
return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
}
const category = draft.category
// Verify zap amount matches expected commission structure
const expectedAmount = 800 // PLATFORM_COMMISSIONS.article.total
if (draft.zapAmount !== expectedAmount) {
return this.buildFailure(
`Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`
)
}
// Encrypt the article content
const { encryptedContent, key, iv } = await encryptArticleContent(draft.content)
// Encrypt the decryption key with the author's public key (for storage in tags)
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
const invoice = await createArticleInvoice(draft)
const extraTags = this.buildArticleExtraTags(draft, category)
const publishedEvent = await this.publishPreview(
draft,
invoice,
presentation.id,
extraTags,
encryptedContent,
encryptedKey
)
if (!publishedEvent) {
return this.buildFailure('Failed to publish article')
}
// Store the decryption key locally for sending after payment
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice, key, iv)
console.log('Article published with encrypted content', {
articleId: publishedEvent.id,
authorPubkey,
timestamp: new Date().toISOString(),
})
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true }
return await encryptAndPublish(draft, authorPubkey, validation.authorPrivateKeyForEncryption, validation.category, presentation.id)
} catch (error) {
console.error('Error publishing article:', error)
return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
return buildFailure(error instanceof Error ? error.message : 'Unknown error')
}
}
@ -208,6 +104,25 @@ export class ArticlePublisher {
* Send private content to a user after payment confirmation
* Returns detailed result with message event ID and verification status
*/
private logSendResult(result: import('./articlePublisherHelpers').SendContentResult, articleId: string, recipientPubkey: string) {
if (result.success) {
console.log('Private content sent successfully', {
articleId,
recipientPubkey,
messageEventId: result.messageEventId,
verified: result.verified,
timestamp: new Date().toISOString(),
})
} else {
console.error('Failed to send private content', {
articleId,
recipientPubkey,
error: result.error,
timestamp: new Date().toISOString(),
})
}
}
async sendPrivateContent(
articleId: string,
recipientPubkey: string,
@ -218,31 +133,11 @@ export class ArticlePublisher {
if (!stored) {
const error = 'Private content not found for article'
console.error(error, { articleId, recipientPubkey })
return {
success: false,
error,
}
return { success: false, error }
}
const result = await sendEncryptedContent(articleId, recipientPubkey, stored, authorPrivateKey)
if (result.success) {
console.log('Private content sent successfully', {
articleId,
recipientPubkey,
messageEventId: result.messageEventId,
verified: result.verified,
timestamp: new Date().toISOString(),
})
} else {
console.error('Failed to send private content', {
articleId,
recipientPubkey,
error: result.error,
timestamp: new Date().toISOString(),
})
}
this.logSendResult(result, articleId, recipientPubkey)
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@ -252,10 +147,7 @@ export class ArticlePublisher {
error: errorMessage,
timestamp: new Date().toISOString(),
})
return {
success: false,
error: errorMessage,
}
return { success: false, error: errorMessage }
}
}
@ -280,11 +172,11 @@ export class ArticlePublisher {
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 tempEventId = `temp_${Math.random().toString(36).substring(7)}`
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction'))
if (!publishedEvent) {
return this.buildFailure('Failed to publish presentation article')
return buildFailure('Failed to publish presentation article')
}
return {
@ -294,7 +186,7 @@ export class ArticlePublisher {
}
} catch (error) {
console.error('Error publishing presentation article:', error)
return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
return buildFailure(error instanceof Error ? error.message : 'Unknown error')
}
}

View File

@ -1,271 +1,3 @@
import { nip04, type Event } from 'nostr-tools'
import { nostrService } from './nostr'
import type { AuthorPresentationDraft } from './articlePublisher'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
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,
}
}
export function parsePresentationEvent(event: Event): import('@/types/nostr').AuthorPresentationArticle | null {
const tags = extractTagsFromEvent(event)
// Check if it's an author type (tag is 'author' in English)
if (tags.type !== 'author') {
return null
}
return {
id: tags.id ?? event.id,
pubkey: event.pubkey,
title: (tags.title as string | undefined) ?? 'Présentation',
preview: (tags.preview as string | undefined) ?? event.content.substring(0, 200),
content: event.content,
createdAt: event.created_at,
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: (tags.mainnetAddress as string | undefined) ?? '',
totalSponsoring: (tags.totalSponsoring as number | undefined) ?? 0,
...(tags.pictureUrl ? { bannerUrl: tags.pictureUrl as string } : {}),
}
}
export function fetchAuthorPresentationFromPool(
pool: SimplePoolWithSub,
pubkey: string
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const filters = [
{
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
}),
limit: 1,
},
]
return new Promise((resolve) => {
let resolved = false
const sub = pool.sub([RELAY_URL], filters)
const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event: Event) => {
const parsed = parsePresentationEvent(event)
if (parsed) {
finalize(parsed)
}
})
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)
})
}
export interface SendContentResult {
success: boolean
messageEventId?: string
error?: string
verified?: boolean
}
export async function sendEncryptedContent(
articleId: string,
recipientPubkey: string,
storedContent: { content: string; authorPubkey: string; decryptionKey?: string; decryptionIV?: string },
authorPrivateKey: string
): Promise<SendContentResult> {
try {
nostrService.setPrivateKey(authorPrivateKey)
nostrService.setPublicKey(storedContent.authorPubkey)
// Send the decryption key instead of the full content
// The key is sent as JSON: { key: string, iv: string }
const keyData = storedContent.decryptionKey && storedContent.decryptionIV
? JSON.stringify({ key: storedContent.decryptionKey, iv: storedContent.decryptionIV })
: storedContent.content // Fallback to old behavior if keys are not available
const encryptedKey = await Promise.resolve(nip04.encrypt(authorPrivateKey, recipientPubkey, keyData))
const privateMessageEvent = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', recipientPubkey],
['e', articleId],
],
content: encryptedKey,
}
const publishedEvent = await nostrService.publishEvent(privateMessageEvent)
if (!publishedEvent) {
console.error('Failed to publish private message event', {
articleId,
recipientPubkey,
authorPubkey: storedContent.authorPubkey,
})
return {
success: false,
error: 'Failed to publish private message event',
}
}
const messageEventId = publishedEvent.id
console.log('Private message published', {
messageEventId,
articleId,
recipientPubkey,
authorPubkey: storedContent.authorPubkey,
timestamp: new Date().toISOString(),
})
const verified = await verifyPrivateMessagePublished(messageEventId, storedContent.authorPubkey, recipientPubkey, articleId)
if (verified) {
console.log('Private message verified on relay', {
messageEventId,
articleId,
recipientPubkey,
})
} else {
console.warn('Private message published but not yet verified on relay', {
messageEventId,
articleId,
recipientPubkey,
})
}
return {
success: true,
messageEventId,
verified,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error sending encrypted content', {
articleId,
recipientPubkey,
authorPubkey: storedContent.authorPubkey,
error: errorMessage,
timestamp: new Date().toISOString(),
})
return {
success: false,
error: errorMessage,
}
}
}
async function verifyPrivateMessagePublished(
messageEventId: string,
authorPubkey: string,
recipientPubkey: string,
articleId: string
): Promise<boolean> {
try {
const pool = nostrService.getPool()
if (!pool) {
console.error('Pool not initialized for message verification', {
messageEventId,
articleId,
recipientPubkey,
})
return false
}
return new Promise((resolve) => {
let resolved = false
const filters = [
{
kinds: [4],
ids: [messageEventId],
authors: [authorPubkey],
'#p': [recipientPubkey],
'#e': [articleId],
limit: 1,
},
]
const sub = (pool as import('@/types/nostr-tools-extended').SimplePoolWithSub).sub([RELAY_URL], filters)
const finalize = (value: boolean) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event) => {
console.log('Private message verified on relay', {
messageEventId: event.id,
articleId,
recipientPubkey,
authorPubkey,
timestamp: new Date().toISOString(),
})
finalize(true)
})
sub.on('eose', () => {
console.warn('Private message not found on relay after EOSE', {
messageEventId,
articleId,
recipientPubkey,
timestamp: new Date().toISOString(),
})
finalize(false)
})
setTimeout(() => {
if (!resolved) {
console.warn('Timeout verifying private message on relay', {
messageEventId,
articleId,
recipientPubkey,
timestamp: new Date().toISOString(),
})
finalize(false)
}
}, 5000)
})
} catch (error) {
console.error('Error verifying private message', {
messageEventId,
articleId,
recipientPubkey,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
return false
}
}
export { buildPresentationEvent, parsePresentationEvent, fetchAuthorPresentationFromPool } from './articlePublisherHelpersPresentation'
export { sendEncryptedContent, type SendContentResult } from './articlePublisherHelpersEncryption'
export { verifyPrivateMessagePublished } from './articlePublisherHelpersVerification'

View File

@ -0,0 +1,86 @@
import { nip04 } from 'nostr-tools'
import { nostrService } from './nostr'
import { publishAndVerifyMessage } from './articlePublisherHelpersVerification'
export interface SendContentResult {
success: boolean
messageEventId?: string
error?: string
verified?: boolean
}
export function prepareKeyData(storedContent: { content: string; decryptionKey?: string; decryptionIV?: string }): string {
if (storedContent.decryptionKey && storedContent.decryptionIV) {
return JSON.stringify({ key: storedContent.decryptionKey, iv: storedContent.decryptionIV })
}
return storedContent.content // Fallback to old behavior if keys are not available
}
export function buildPrivateMessageEvent(recipientPubkey: string, articleId: string, encryptedKey: string) {
return {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', recipientPubkey],
['e', articleId],
],
content: encryptedKey,
}
}
async function publishEncryptedMessage(
articleId: string,
recipientPubkey: string,
authorPubkey: string,
authorPrivateKey: string,
keyData: string
): Promise<{ eventId: string } | null> {
const encryptedKey = await Promise.resolve(nip04.encrypt(authorPrivateKey, recipientPubkey, keyData))
const privateMessageEvent = buildPrivateMessageEvent(recipientPubkey, articleId, encryptedKey)
const publishedEvent = await nostrService.publishEvent(privateMessageEvent)
if (!publishedEvent) {
console.error('Failed to publish private message event', { articleId, recipientPubkey, authorPubkey })
return null
}
console.log('Private message published', {
messageEventId: publishedEvent.id,
articleId,
recipientPubkey,
authorPubkey,
timestamp: new Date().toISOString(),
})
return { eventId: publishedEvent.id }
}
export async function sendEncryptedContent(
articleId: string,
recipientPubkey: string,
storedContent: { content: string; authorPubkey: string; decryptionKey?: string; decryptionIV?: string },
authorPrivateKey: string
): Promise<SendContentResult> {
try {
nostrService.setPrivateKey(authorPrivateKey)
nostrService.setPublicKey(storedContent.authorPubkey)
const keyData = prepareKeyData(storedContent)
const publishResult = await publishEncryptedMessage(articleId, recipientPubkey, storedContent.authorPubkey, authorPrivateKey, keyData)
if (!publishResult) {
return { success: false, error: 'Failed to publish private message event' }
}
const verified = await publishAndVerifyMessage(articleId, recipientPubkey, storedContent.authorPubkey, publishResult.eventId)
return { success: true, messageEventId: publishResult.eventId, verified }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error sending encrypted content', {
articleId,
recipientPubkey,
authorPubkey: storedContent.authorPubkey,
error: errorMessage,
timestamp: new Date().toISOString(),
})
return { success: false, error: errorMessage }
}
}

View File

@ -0,0 +1,89 @@
import { type Event } 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'
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,
}
}
export function parsePresentationEvent(event: Event): import('@/types/nostr').AuthorPresentationArticle | null {
const tags = extractTagsFromEvent(event)
// Check if it's an author type (tag is 'author' in English)
if (tags.type !== 'author') {
return null
}
return {
id: tags.id ?? event.id,
pubkey: event.pubkey,
title: tags.title ?? 'Présentation',
preview: tags.preview ?? event.content.substring(0, 200),
content: event.content,
createdAt: event.created_at,
zapAmount: 0,
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: tags.mainnetAddress ?? '',
totalSponsoring: tags.totalSponsoring ?? 0,
...(tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string' ? { bannerUrl: tags.pictureUrl } : {}),
}
}
export function fetchAuthorPresentationFromPool(
pool: SimplePoolWithSub,
pubkey: string
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
const filters = [
{
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
}),
limit: 1,
},
]
return new Promise((resolve) => {
let resolved = false
const relayUrl = getPrimaryRelaySync()
const sub = pool.sub([relayUrl], filters)
const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event: Event) => {
const parsed = parsePresentationEvent(event)
if (parsed) {
finalize(parsed)
}
})
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)
})
}

View File

@ -0,0 +1,156 @@
import { nostrService } from './nostr'
import { getPrimaryRelaySync } from './config'
export function createMessageVerificationFilters(messageEventId: string, authorPubkey: string, recipientPubkey: string, articleId: string) {
return [
{
kinds: [4],
ids: [messageEventId],
authors: [authorPubkey],
'#p': [recipientPubkey],
'#e': [articleId],
limit: 1,
},
]
}
export function handleMessageVerificationEvent(
event: import('nostr-tools').Event,
articleId: string,
recipientPubkey: string,
authorPubkey: string,
finalize: (value: boolean) => void
): void {
console.log('Private message verified on relay', {
messageEventId: event.id,
articleId,
recipientPubkey,
authorPubkey,
timestamp: new Date().toISOString(),
})
finalize(true)
}
export function setupMessageVerificationHandlers(
sub: import('@/types/nostr-tools-extended').SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
messageEventId: string,
articleId: string,
recipientPubkey: string,
authorPubkey: string,
finalize: (value: boolean) => void,
isResolved: () => boolean
): void {
sub.on('event', (event) => {
handleMessageVerificationEvent(event, articleId, recipientPubkey, authorPubkey, finalize)
})
sub.on('eose', () => {
console.warn('Private message not found on relay after EOSE', {
messageEventId,
articleId,
recipientPubkey,
timestamp: new Date().toISOString(),
})
finalize(false)
})
setTimeout(() => {
if (!isResolved()) {
console.warn('Timeout verifying private message on relay', {
messageEventId,
articleId,
recipientPubkey,
timestamp: new Date().toISOString(),
})
finalize(false)
}
}, 5000)
}
function createMessageVerificationSubscription(
pool: import('@/types/nostr-tools-extended').SimplePoolWithSub,
messageEventId: string,
authorPubkey: string,
recipientPubkey: string,
articleId: string
) {
const filters = createMessageVerificationFilters(messageEventId, authorPubkey, recipientPubkey, articleId)
const relayUrl = getPrimaryRelaySync()
return pool.sub([relayUrl], filters)
}
function createVerificationPromise(
sub: import('@/types/nostr-tools-extended').SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
messageEventId: string,
articleId: string,
recipientPubkey: string,
authorPubkey: string
): Promise<boolean> {
return new Promise((resolve) => {
let resolved = false
const finalize = (value: boolean) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
setupMessageVerificationHandlers(sub, messageEventId, articleId, recipientPubkey, authorPubkey, finalize, () => resolved)
})
}
export function verifyPrivateMessagePublished(
messageEventId: string,
authorPubkey: string,
recipientPubkey: string,
articleId: string
): Promise<boolean> {
try {
const pool = nostrService.getPool()
if (!pool) {
console.error('Pool not initialized for message verification', {
messageEventId,
articleId,
recipientPubkey,
})
return Promise.resolve(false)
}
const sub = createMessageVerificationSubscription(
pool as import('@/types/nostr-tools-extended').SimplePoolWithSub,
messageEventId,
authorPubkey,
recipientPubkey,
articleId
)
return createVerificationPromise(sub, messageEventId, articleId, recipientPubkey, authorPubkey)
} catch (error) {
console.error('Error verifying private message', {
messageEventId,
articleId,
recipientPubkey,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
return Promise.resolve(false)
}
}
export async function publishAndVerifyMessage(
articleId: string,
recipientPubkey: string,
authorPubkey: string,
messageEventId: string
): Promise<boolean> {
const verified = await verifyPrivateMessagePublished(messageEventId, authorPubkey, recipientPubkey, articleId)
if (verified) {
console.log('Private message verified on relay', { messageEventId, articleId, recipientPubkey })
} else {
console.warn('Private message published but not yet verified on relay', { messageEventId, articleId, recipientPubkey })
}
return verified
}

View File

@ -0,0 +1,66 @@
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'
export function buildFailure(error?: string): PublishedArticle {
const base: PublishedArticle = {
articleId: '',
previewEventId: '',
success: false,
}
return error ? { ...base, error } : base
}
export async function publishPreview(
draft: ArticleDraft,
invoice: AlbyInvoice,
presentationId: string,
extraTags?: string[][],
encryptedContent?: string,
encryptedKey?: string
): Promise<import('nostr-tools').Event | null> {
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null
}
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(
draft: ArticleDraft,
authorPubkey: string,
authorPrivateKeyForEncryption: string,
category: NonNullable<ArticleDraft['category']>,
presentationId: string
): Promise<PublishedArticle> {
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 publishedEvent = await publishPreview(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
if (!publishedEvent) {
return buildFailure('Failed to publish article')
}
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice, key, iv)
console.log('Article published with encrypted content', {
articleId: publishedEvent.id,
authorPubkey,
timestamp: new Date().toISOString(),
})
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true }
}

View File

@ -0,0 +1,31 @@
import type { AlbyInvoice } from '@/types/alby'
import type { MediaRef } from '@/types/nostr'
export interface ArticleDraft {
title: string
preview: string
content: string // Full content that will be sent as private message after payment
zapAmount: number
category?: 'science-fiction' | 'scientific-research'
seriesId?: string
bannerUrl?: string
media?: MediaRef[]
}
export interface AuthorPresentationDraft {
title: string
preview: string
content: string
presentation: string
contentDescription: string
mainnetAddress: string
pictureUrl?: string | undefined
}
export interface PublishedArticle {
articleId: string
previewEventId: string
invoice?: AlbyInvoice // Invoice created by author (required if success)
success: boolean
error?: string
}

View File

@ -0,0 +1,39 @@
import { nostrService } from './nostr'
import type { ArticleDraft } from './articlePublisherTypes'
export function prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
nostrService.setPublicKey(authorPubkey)
if (authorPrivateKey) {
nostrService.setPrivateKey(authorPrivateKey)
return { success: true }
}
const existingPrivateKey = nostrService.getPrivateKey()
if (!existingPrivateKey) {
return {
success: false,
error:
'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
}
}
return { success: true }
}
export function isValidCategory(category?: ArticleDraft['category']): category is NonNullable<ArticleDraft['category']> {
return category === 'science-fiction' || category === 'scientific-research'
}
export interface ValidationResult {
success: false
error: string
}
export interface ValidationSuccess {
success: true
authorPrivateKeyForEncryption: string
category: NonNullable<ArticleDraft['category']>
}
export type PublishValidationResult = ValidationResult | ValidationSuccess

View File

@ -4,15 +4,9 @@ import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Article } from '@/types/nostr'
import { parseArticleFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000, limit: number = 100): Promise<Article[]> {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
function createSeriesSubscription(poolWithSub: SimplePoolWithSub, seriesId: string, limit: number) {
const filters = [
{
...buildTagFilter({
@ -22,10 +16,20 @@ export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000,
limit,
},
]
const relayUrl = getPrimaryRelaySync()
return poolWithSub.sub([relayUrl], filters)
}
export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000, limit: number = 100): Promise<Article[]> {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const sub = createSeriesSubscription(poolWithSub, seriesId, limit)
return new Promise<Article[]>((resolve) => {
const results: Article[] = []
const sub = poolWithSub.sub([RELAY_URL], filters)
let finished = false
const done = () => {

View File

@ -22,6 +22,28 @@ export class AutomaticTransferService {
* Transfer author portion after article payment
* Creates a Lightning invoice from the platform to the author
*/
private logTransferRequired(type: 'article' | 'review', id: string, pubkey: string, amount: number, recipient: string, platformCommission: number) {
const logData = {
[type === 'article' ? 'articleId' : 'reviewId']: id,
[type === 'article' ? 'articlePubkey' : 'reviewerPubkey']: pubkey,
amount,
recipient,
platformCommission,
timestamp: new Date().toISOString(),
}
console.log(`Automatic transfer required${type === 'review' ? ' for review' : ''}`, logData)
}
private buildTransferError(error: unknown, recipient: string, amount: number = 0): TransferResult {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
error: errorMessage,
amount,
recipient,
}
}
async transferAuthorPortion(
authorLightningAddress: string,
articleId: string,
@ -40,23 +62,8 @@ export class AutomaticTransferService {
}
}
// In a real implementation, this would:
// 1. Create a Lightning invoice from platform to author
// 2. Pay the invoice automatically
// 3. Track the transfer
// For now, we log the transfer that should be made
console.log('Automatic transfer required', {
articleId,
articlePubkey,
amount: split.author,
recipient: authorLightningAddress,
platformCommission: split.platform,
timestamp: new Date().toISOString(),
})
// Track the transfer requirement
await this.trackTransferRequirement('article', articleId, articlePubkey, split.author, authorLightningAddress)
this.logTransferRequired('article', articleId, articlePubkey, split.author, authorLightningAddress, split.platform)
this.trackTransferRequirement('article', articleId, articlePubkey, split.author, authorLightningAddress)
return {
success: true,
@ -64,19 +71,13 @@ export class AutomaticTransferService {
recipient: authorLightningAddress,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error transferring author portion', {
articleId,
articlePubkey,
error: errorMessage,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
return {
success: false,
error: errorMessage,
amount: 0,
recipient: authorLightningAddress,
}
return this.buildTransferError(error, authorLightningAddress)
}
}
@ -101,16 +102,8 @@ export class AutomaticTransferService {
}
}
console.log('Automatic transfer required for review', {
reviewId,
reviewerPubkey,
amount: split.reviewer,
recipient: reviewerLightningAddress,
platformCommission: split.platform,
timestamp: new Date().toISOString(),
})
await this.trackTransferRequirement('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress)
this.logTransferRequired('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress, split.platform)
this.trackTransferRequirement('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress)
return {
success: true,
@ -118,19 +111,13 @@ export class AutomaticTransferService {
recipient: reviewerLightningAddress,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error transferring reviewer portion', {
reviewId,
reviewerPubkey,
error: errorMessage,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
return {
success: false,
error: errorMessage,
amount: 0,
recipient: reviewerLightningAddress,
}
return this.buildTransferError(error, reviewerLightningAddress)
}
}
@ -138,13 +125,13 @@ export class AutomaticTransferService {
* Track transfer requirement for later processing
* In production, this would be stored in a database or queue
*/
private async trackTransferRequirement(
private trackTransferRequirement(
type: 'article' | 'review',
id: string,
recipientPubkey: string,
amount: number,
recipientAddress: string
): Promise<void> {
): void {
// In production, this would:
// 1. Store in a database/queue for processing
// 2. Trigger automatic transfer via platform's Lightning node

97
lib/config.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* Configuration utilities
* Provides access to Nostr relays, NIP-95 APIs, and platform settings
* All values are stored in IndexedDB with hardcoded defaults
*/
import { configStorage, getPrimaryRelaySync, getPrimaryNip95ApiSync, getPlatformLightningAddressSync } from './configStorage'
/**
* Get primary relay URL
* Uses IndexedDB if available, otherwise returns default
*/
export async function getPrimaryRelay(): Promise<string> {
if (typeof window === 'undefined') {
return getPrimaryRelaySync()
}
try {
return await configStorage.getPrimaryRelay()
} catch (error) {
console.error('Error getting primary relay from IndexedDB:', error)
return getPrimaryRelaySync()
}
}
/**
* Get all enabled relay URLs
* Uses IndexedDB if available, otherwise returns default
*/
export async function getEnabledRelays(): Promise<string[]> {
if (typeof window === 'undefined') {
return [getPrimaryRelaySync()]
}
try {
return await configStorage.getEnabledRelays()
} catch (error) {
console.error('Error getting enabled relays from IndexedDB:', error)
return [getPrimaryRelaySync()]
}
}
/**
* Get primary NIP-95 API URL
* Uses IndexedDB if available, otherwise returns default
*/
export async function getPrimaryNip95Api(): Promise<string> {
if (typeof window === 'undefined') {
return getPrimaryNip95ApiSync()
}
try {
return await configStorage.getPrimaryNip95Api()
} catch (error) {
console.error('Error getting primary NIP-95 API from IndexedDB:', error)
return getPrimaryNip95ApiSync()
}
}
/**
* Get all enabled NIP-95 API URLs
* Uses IndexedDB if available, otherwise returns default
*/
export async function getEnabledNip95Apis(): Promise<string[]> {
if (typeof window === 'undefined') {
return [getPrimaryNip95ApiSync()]
}
try {
return await configStorage.getEnabledNip95Apis()
} catch (error) {
console.error('Error getting enabled NIP-95 APIs from IndexedDB:', error)
return [getPrimaryNip95ApiSync()]
}
}
/**
* Get platform Lightning address
* Uses IndexedDB if available, otherwise returns default
*/
export async function getPlatformLightningAddress(): Promise<string> {
if (typeof window === 'undefined') {
return getPlatformLightningAddressSync()
}
try {
return await configStorage.getPlatformLightningAddress()
} catch (error) {
console.error('Error getting platform Lightning address from IndexedDB:', error)
return getPlatformLightningAddressSync()
}
}
/**
* Re-export synchronous getters from configStorage
*/
export { getPrimaryRelaySync, getPrimaryNip95ApiSync, getPlatformLightningAddressSync } from './configStorage'

280
lib/configStorage.ts Normal file
View File

@ -0,0 +1,280 @@
/**
* Configuration storage service using IndexedDB
* Stores Nostr relay URLs and NIP-95 API endpoints
*/
import type { ConfigData } from './configStorageTypes'
import { DEFAULT_RELAYS, DEFAULT_NIP95_APIS, DEFAULT_PLATFORM_LIGHTNING_ADDRESS } from './configStorageTypes'
import {
getEnabledRelays,
getPrimaryRelayFromConfig,
addRelayToConfig,
updateRelayInConfig,
removeRelayFromConfig,
} from './configStorageRelays'
import {
getEnabledNip95Apis,
getPrimaryNip95ApiFromConfig,
addNip95ApiToConfig,
updateNip95ApiInConfig,
removeNip95ApiFromConfig,
} from './configStorageNip95'
const DB_NAME = 'nostr_paywall_config'
const DB_VERSION = 1
const STORE_NAME = 'config'
/**
* IndexedDB storage service for application configuration
*/
export class ConfigStorage {
private db: IDBDatabase | null = null
private initPromise: Promise<void> | null = null
/**
* Initialize the IndexedDB database
*/
private async init(): Promise<void> {
if (this.db) {
return
}
if (this.initPromise) {
return this.initPromise
}
this.initPromise = this.openDatabase()
try {
await this.initPromise
} catch (error) {
this.initPromise = null
throw error
}
}
private openDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window === 'undefined' || !window.indexedDB) {
reject(new Error('IndexedDB is not available. This application requires IndexedDB support.'))
return
}
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB: ${request.error}`))
}
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'key' })
}
}
})
}
/**
* Get all configuration from IndexedDB or return defaults
*/
async getConfig(): Promise<ConfigData> {
try {
await this.init()
if (!this.db) {
return this.getDefaultConfig()
}
const db = this.db
return new Promise((resolve) => {
const transaction = db.transaction([STORE_NAME], 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.get('config')
request.onsuccess = () => {
const result = request.result as { key: string; value: ConfigData } | undefined
if (!result?.value) {
resolve(this.getDefaultConfig())
return
}
resolve(result.value)
}
request.onerror = () => {
console.warn('Failed to read config from IndexedDB, using defaults')
resolve(this.getDefaultConfig())
}
})
} catch (error) {
console.error('Error getting config from IndexedDB:', error)
return this.getDefaultConfig()
}
}
/**
* Save configuration to IndexedDB
*/
async saveConfig(config: ConfigData): Promise<void> {
try {
await this.init()
if (!this.db) {
throw new Error('Database not initialized')
}
const db = this.db
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.put({
key: 'config',
value: {
...config,
updatedAt: Date.now(),
},
})
request.onsuccess = () => resolve()
request.onerror = () => reject(new Error(`Failed to save config: ${request.error}`))
})
} catch (error) {
console.error('Error saving config to IndexedDB:', error)
throw error
}
}
/**
* Get default configuration
*/
private getDefaultConfig(): ConfigData {
return {
relays: [...DEFAULT_RELAYS],
nip95Apis: [...DEFAULT_NIP95_APIS],
platformLightningAddress: DEFAULT_PLATFORM_LIGHTNING_ADDRESS,
updatedAt: Date.now(),
}
}
/**
* Get enabled relays sorted by priority
*/
async getEnabledRelays(): Promise<string[]> {
const config = await this.getConfig()
return getEnabledRelays(config)
}
/**
* Get primary relay URL (first enabled relay)
*/
async getPrimaryRelay(): Promise<string> {
const config = await this.getConfig()
return getPrimaryRelayFromConfig(config)
}
/**
* Get enabled NIP-95 APIs sorted by priority
*/
async getEnabledNip95Apis(): Promise<string[]> {
const config = await this.getConfig()
return getEnabledNip95Apis(config)
}
/**
* Get primary NIP-95 API URL (first enabled API)
*/
async getPrimaryNip95Api(): Promise<string> {
const config = await this.getConfig()
return getPrimaryNip95ApiFromConfig(config)
}
/**
* Get platform Lightning address
*/
async getPlatformLightningAddress(): Promise<string> {
const config = await this.getConfig()
return config.platformLightningAddress || DEFAULT_PLATFORM_LIGHTNING_ADDRESS
}
/**
* Add a new relay
*/
async addRelay(url: string, enabled: boolean = true, priority?: number): Promise<void> {
const config = await this.getConfig()
const updatedConfig = addRelayToConfig(config, url, enabled, priority)
await this.saveConfig(updatedConfig)
}
/**
* Add a new NIP-95 API
*/
async addNip95Api(url: string, enabled: boolean = true, priority?: number): Promise<void> {
const config = await this.getConfig()
const updatedConfig = addNip95ApiToConfig(config, url, enabled, priority)
await this.saveConfig(updatedConfig)
}
/**
* Update relay configuration
*/
async updateRelay(id: string, updates: Partial<Omit<import('./configStorageTypes').RelayConfig, 'id' | 'createdAt'>>): Promise<void> {
const config = await this.getConfig()
const updatedConfig = updateRelayInConfig(config, id, updates)
await this.saveConfig(updatedConfig)
}
/**
* Update NIP-95 API configuration
*/
async updateNip95Api(id: string, updates: Partial<Omit<import('./configStorageTypes').Nip95Config, 'id' | 'createdAt'>>): Promise<void> {
const config = await this.getConfig()
const updatedConfig = updateNip95ApiInConfig(config, id, updates)
await this.saveConfig(updatedConfig)
}
/**
* Remove a relay
*/
async removeRelay(id: string): Promise<void> {
const config = await this.getConfig()
const updatedConfig = removeRelayFromConfig(config, id)
await this.saveConfig(updatedConfig)
}
/**
* Remove a NIP-95 API
*/
async removeNip95Api(id: string): Promise<void> {
const config = await this.getConfig()
const updatedConfig = removeNip95ApiFromConfig(config, id)
await this.saveConfig(updatedConfig)
}
/**
* Set platform Lightning address
*/
async setPlatformLightningAddress(address: string): Promise<void> {
const config = await this.getConfig()
config.platformLightningAddress = address
await this.saveConfig(config)
}
/**
* Check if IndexedDB is available
*/
static isAvailable(): boolean {
return typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined'
}
}
export const configStorage = new ConfigStorage()
export { getPrimaryRelaySync, getPrimaryNip95ApiSync, getPlatformLightningAddressSync } from './configStorageSync'

80
lib/configStorageNip95.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* NIP-95 API management methods for ConfigStorage
*/
import type { Nip95Config, ConfigData } from './configStorageTypes'
import { DEFAULT_NIP95_APIS } from './configStorageTypes'
export function getEnabledNip95Apis(config: ConfigData): string[] {
return config.nip95Apis
.filter((api) => api.enabled)
.sort((a, b) => a.priority - b.priority)
.map((api) => api.url)
}
export function getPrimaryNip95ApiFromConfig(config: ConfigData): string {
const apis = getEnabledNip95Apis(config)
if (apis.length === 0) {
const defaultApi = DEFAULT_NIP95_APIS[0]
if (!defaultApi) {
throw new Error('No default NIP-95 API configured')
}
return defaultApi.url
}
const primaryApi = apis[0]
if (!primaryApi) {
const defaultApi = DEFAULT_NIP95_APIS[0]
if (!defaultApi) {
throw new Error('No default NIP-95 API configured')
}
return defaultApi.url
}
return primaryApi
}
export function createNip95Config(url: string, enabled: boolean, priority: number): Nip95Config {
return {
id: `nip95_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
url,
enabled,
priority,
createdAt: Date.now(),
}
}
export function addNip95ApiToConfig(config: ConfigData, url: string, enabled: boolean, priority?: number): ConfigData {
const maxPriority = Math.max(...config.nip95Apis.map((a) => a.priority), 0)
const newApi = createNip95Config(url, enabled, priority ?? maxPriority + 1)
return {
...config,
nip95Apis: [...config.nip95Apis, newApi],
}
}
export function updateNip95ApiInConfig(
config: ConfigData,
id: string,
updates: Partial<Omit<Nip95Config, 'id' | 'createdAt'>>
): ConfigData {
const apiIndex = config.nip95Apis.findIndex((a) => a.id === id)
if (apiIndex === -1) {
throw new Error(`NIP-95 API with id ${id} not found`)
}
const existingApi = config.nip95Apis[apiIndex]
const updatedApis = [...config.nip95Apis]
updatedApis[apiIndex] = {
...existingApi,
...updates,
} as Nip95Config
return {
...config,
nip95Apis: updatedApis,
}
}
export function removeNip95ApiFromConfig(config: ConfigData, id: string): ConfigData {
return {
...config,
nip95Apis: config.nip95Apis.filter((a) => a.id !== id),
}
}

View File

@ -0,0 +1,80 @@
/**
* Relay management methods for ConfigStorage
*/
import type { RelayConfig, ConfigData } from './configStorageTypes'
import { DEFAULT_RELAYS } from './configStorageTypes'
export function getEnabledRelays(config: ConfigData): string[] {
return config.relays
.filter((relay) => relay.enabled)
.sort((a, b) => a.priority - b.priority)
.map((relay) => relay.url)
}
export function getPrimaryRelayFromConfig(config: ConfigData): string {
const relays = getEnabledRelays(config)
if (relays.length === 0) {
const defaultRelay = DEFAULT_RELAYS[0]
if (!defaultRelay) {
throw new Error('No default relay configured')
}
return defaultRelay.url
}
const primaryRelay = relays[0]
if (!primaryRelay) {
const defaultRelay = DEFAULT_RELAYS[0]
if (!defaultRelay) {
throw new Error('No default relay configured')
}
return defaultRelay.url
}
return primaryRelay
}
export function createRelayConfig(url: string, enabled: boolean, priority: number): RelayConfig {
return {
id: `relay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
url,
enabled,
priority,
createdAt: Date.now(),
}
}
export function addRelayToConfig(config: ConfigData, url: string, enabled: boolean, priority?: number): ConfigData {
const maxPriority = Math.max(...config.relays.map((r) => r.priority), 0)
const newRelay = createRelayConfig(url, enabled, priority ?? maxPriority + 1)
return {
...config,
relays: [...config.relays, newRelay],
}
}
export function updateRelayInConfig(
config: ConfigData,
id: string,
updates: Partial<Omit<RelayConfig, 'id' | 'createdAt'>>
): ConfigData {
const relayIndex = config.relays.findIndex((r) => r.id === id)
if (relayIndex === -1) {
throw new Error(`Relay with id ${id} not found`)
}
const existingRelay = config.relays[relayIndex]
const updatedRelays = [...config.relays]
updatedRelays[relayIndex] = {
...existingRelay,
...updates,
} as RelayConfig
return {
...config,
relays: updatedRelays,
}
}
export function removeRelayFromConfig(config: ConfigData, id: string): ConfigData {
return {
...config,
relays: config.relays.filter((r) => r.id !== id),
}
}

38
lib/configStorageSync.ts Normal file
View File

@ -0,0 +1,38 @@
/**
* Synchronous getters for backward compatibility
* Returns defaults if IndexedDB is not ready
*/
import { DEFAULT_RELAYS, DEFAULT_NIP95_APIS, DEFAULT_PLATFORM_LIGHTNING_ADDRESS } from './configStorageTypes'
/**
* Synchronous getter for primary relay (for backward compatibility)
* Returns default if IndexedDB is not ready
*/
export function getPrimaryRelaySync(): string {
const defaultRelay = DEFAULT_RELAYS[0]
if (!defaultRelay) {
throw new Error('No default relay configured')
}
return defaultRelay.url
}
/**
* Synchronous getter for primary NIP-95 API (for backward compatibility)
* Returns default if IndexedDB is not ready
*/
export function getPrimaryNip95ApiSync(): string {
const defaultApi = DEFAULT_NIP95_APIS[0]
if (!defaultApi) {
throw new Error('No default NIP-95 API configured')
}
return defaultApi.url
}
/**
* Synchronous getter for platform Lightning address (for backward compatibility)
* Returns default if IndexedDB is not ready
*/
export function getPlatformLightningAddressSync(): string {
return DEFAULT_PLATFORM_LIGHTNING_ADDRESS
}

51
lib/configStorageTypes.ts Normal file
View File

@ -0,0 +1,51 @@
/**
* Types and default configurations for ConfigStorage
*/
export interface RelayConfig {
id: string
url: string
enabled: boolean
priority: number
createdAt: number
}
export interface Nip95Config {
id: string
url: string
enabled: boolean
priority: number
createdAt: number
}
export interface ConfigData {
relays: RelayConfig[]
nip95Apis: Nip95Config[]
platformLightningAddress: string
updatedAt: number
}
/**
* Default configuration values (hardcoded in the code)
*/
export const DEFAULT_RELAYS: RelayConfig[] = [
{
id: 'default',
url: 'wss://relay.damus.io',
enabled: true,
priority: 1,
createdAt: Date.now(),
},
]
export const DEFAULT_NIP95_APIS: Nip95Config[] = [
{
id: 'default',
url: 'https://nostr.build/api/v2/upload',
enabled: true,
priority: 1,
createdAt: Date.now(),
},
]
export const DEFAULT_PLATFORM_LIGHTNING_ADDRESS = ''

View File

@ -1,7 +1,6 @@
import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
import { getPrimaryRelaySync } from './config'
export interface ContentDeliveryStatus {
messageEventId: string | null
@ -15,7 +14,94 @@ export interface ContentDeliveryStatus {
* Verify that private content was successfully delivered to recipient
* Checks multiple aspects to ensure delivery certainty
*/
export async function verifyContentDelivery(
function createContentDeliveryFilters(authorPubkey: string, recipientPubkey: string, articleId: string, messageEventId: string) {
const filters: Array<{
kinds: number[]
ids?: string[]
authors: string[]
'#p': string[]
'#e': string[]
limit: number
}> = [
{
kinds: [4],
authors: [authorPubkey],
'#p': [recipientPubkey],
'#e': [articleId],
limit: 1,
},
]
if (messageEventId && filters[0]) {
filters[0].ids = [messageEventId]
}
return filters
}
function setupContentDeliveryHandlers(
sub: SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
status: ContentDeliveryStatus,
finalize: (result: ContentDeliveryStatus) => void,
isResolved: () => boolean
): void {
sub.on('event', (event) => {
status.published = true
status.verifiedOnRelay = true
status.messageEventId = event.id
status.retrievable = true
finalize(status)
})
sub.on('eose', () => {
if (!status.published) {
status.error = 'Message not found on relay'
}
finalize(status)
})
setTimeout(() => {
if (!isResolved()) {
if (!status.published) {
status.error = 'Timeout waiting for message verification'
}
finalize(status)
}
}, 5000)
}
function createContentDeliverySubscription(
pool: SimplePoolWithSub,
authorPubkey: string,
recipientPubkey: string,
articleId: string,
messageEventId: string
) {
const filters = createContentDeliveryFilters(authorPubkey, recipientPubkey, articleId, messageEventId)
const relayUrl = getPrimaryRelaySync()
return pool.sub([relayUrl], filters)
}
function createContentDeliveryPromise(
sub: SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
status: ContentDeliveryStatus
): Promise<ContentDeliveryStatus> {
return new Promise((resolve) => {
let resolved = false
const finalize = (result: ContentDeliveryStatus) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(result)
}
setupContentDeliveryHandlers(sub, status, finalize, () => resolved)
})
}
export function verifyContentDelivery(
articleId: string,
authorPubkey: string,
recipientPubkey: string,
@ -32,72 +118,15 @@ export async function verifyContentDelivery(
const pool = nostrService.getPool()
if (!pool) {
status.error = 'Pool not initialized'
return status
return Promise.resolve(status)
}
const poolWithSub = pool as SimplePoolWithSub
const filters: Array<{
kinds: number[]
ids?: string[]
authors: string[]
'#p': string[]
'#e': string[]
limit: number
}> = [
{
kinds: [4],
authors: [authorPubkey],
'#p': [recipientPubkey],
'#e': [articleId],
limit: 1,
},
]
if (messageEventId && filters[0]) {
filters[0].ids = [messageEventId]
}
return new Promise((resolve) => {
let resolved = false
const sub = poolWithSub.sub([RELAY_URL], filters)
const finalize = (result: ContentDeliveryStatus) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(result)
}
sub.on('event', (event) => {
status.published = true
status.verifiedOnRelay = true
status.messageEventId = event.id
status.retrievable = true
finalize(status)
})
sub.on('eose', () => {
if (!status.published) {
status.error = 'Message not found on relay'
}
finalize(status)
})
setTimeout(() => {
if (!resolved) {
if (!status.published) {
status.error = 'Timeout waiting for message verification'
}
finalize(status)
}
}, 5000)
})
const sub = createContentDeliverySubscription(poolWithSub, authorPubkey, recipientPubkey, articleId, messageEventId)
return createContentDeliveryPromise(sub, status)
} catch (error) {
status.error = error instanceof Error ? error.message : 'Unknown error'
return status
return Promise.resolve(status)
}
}

View File

@ -22,50 +22,53 @@ export interface FundingStats {
* This is an approximation based on commission rates
* Actual calculation would require querying all transactions
*/
export async function calculatePlatformFunds(_authorPubkeys: string[]): Promise<FundingStats> {
let totalSats = 0
// Calculate article commissions (from zap receipts with kind_type: purchase)
// Each article payment is 800 sats, platform gets 100 sats
// This is an approximation - in reality we'd query all zap receipts
async function calculateArticleCommissions(): Promise<number> {
try {
const articleCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'purchase',
})
// Estimate: assume 100 sats commission per purchase (800 total, 700 author, 100 platform)
// This is simplified - actual calculation would need to track each payment
totalSats += Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
return Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
} catch (e) {
console.error('Error calculating article commissions:', e)
return 0
}
}
// Calculate review commissions (from zap receipts with kind_type: review_tip)
// Each review tip is 70 sats, platform gets 21 sats
async function calculateReviewCommissions(): Promise<number> {
try {
const reviewCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'review_tip',
})
// Estimate: assume 21 sats commission per review tip (70 total, 49 reviewer, 21 platform)
totalSats += Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
return Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
} catch (e) {
console.error('Error calculating review commissions:', e)
return 0
}
}
// Calculate sponsoring commissions (from zap receipts with kind_type: sponsoring)
// Each sponsoring is 0.046 BTC, platform gets 0.004 BTC (400,000 sats)
async function calculateSponsoringCommissions(): Promise<number> {
try {
const sponsoringCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'sponsoring',
})
// Estimate: assume 400,000 sats commission per sponsoring (4,600,000 total, 4,200,000 author, 400,000 platform)
totalSats += Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
return Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
} catch (e) {
console.error('Error calculating sponsoring commissions:', e)
return 0
}
}
export async function calculatePlatformFunds(_authorPubkeys: string[]): Promise<FundingStats> {
const [articleCommissions, reviewCommissions, sponsoringCommissions] = await Promise.all([
calculateArticleCommissions(),
calculateReviewCommissions(),
calculateSponsoringCommissions(),
])
const totalSats = articleCommissions + reviewCommissions + sponsoringCommissions
const totalBTC = totalSats / 100_000_000
const progressPercent = Math.min(100, (totalBTC / FUNDING_TARGET_BTC) * 100)

View File

@ -30,7 +30,7 @@ export function getLocale(): Locale {
* Load translations from a flat text file
* Format: key=value (one per line, empty lines and lines starting with # are ignored)
*/
export async function loadTranslations(locale: Locale, translationsText: string): Promise<void> {
export function loadTranslations(locale: Locale, translationsText: string): void {
const translationsMap: Translations = {}
const lines = translationsText.split('\n')

165
lib/keyManagement.ts Normal file
View File

@ -0,0 +1,165 @@
import { nip19, getPublicKey, generatePrivateKey } from 'nostr-tools'
import { generateRecoveryPhrase } from './keyManagementRecovery'
import { deriveKeyFromPhrase, encryptNsec, decryptNsec } from './keyManagementEncryption'
import {
storeAccountFlag,
hasAccountFlag,
removeAccountFlag,
getEncryptedKey,
setEncryptedKey,
getPublicKeys as getStoredPublicKeys,
setPublicKeys,
deleteStoredKeys,
} from './keyManagementStorage'
/**
* Key management service
*/
export class KeyManagementService {
/**
* Generate a new Nostr key pair
* Returns the private key (hex) and public key (hex)
*/
generateKeyPair(): { privateKey: string; publicKey: string; npub: string } {
const privateKeyHex = generatePrivateKey()
const publicKeyHex = getPublicKey(privateKeyHex)
const npub = nip19.npubEncode(publicKeyHex)
return {
privateKey: privateKeyHex,
publicKey: publicKeyHex,
npub,
}
}
/**
* Import a private key (accepts hex or nsec format)
* Returns the private key (hex), public key (hex), and npub
*/
importPrivateKey(privateKey: string): { privateKey: string; publicKey: string; npub: string } {
let privateKeyHex: string
// Try to decode as nsec
try {
const decoded = nip19.decode(privateKey)
if (decoded.type === 'nsec' && typeof decoded.data === 'string') {
privateKeyHex = decoded.data
} else {
throw new Error('Invalid nsec format')
}
} catch {
// Assume it's already a hex string
privateKeyHex = privateKey
}
const publicKeyHex = getPublicKey(privateKeyHex)
const npub = nip19.npubEncode(publicKeyHex)
return {
privateKey: privateKeyHex,
publicKey: publicKeyHex,
npub,
}
}
/**
* Create a new account: generate/import key, encrypt it, and store it
* Returns the recovery phrase and npub
*/
async createAccount(privateKey?: string): Promise<{
recoveryPhrase: string[]
npub: string
publicKey: string
}> {
// Generate or import key pair
const keyPair = privateKey ? this.importPrivateKey(privateKey) : this.generateKeyPair()
// Generate recovery phrase
const recoveryPhrase = generateRecoveryPhrase()
// Derive encryption key from recovery phrase
const derivedKey = await deriveKeyFromPhrase(recoveryPhrase)
// Encrypt the private key
const encryptedNsec = await encryptNsec(derivedKey, keyPair.privateKey)
// Store encrypted nsec in IndexedDB
await setEncryptedKey(encryptedNsec)
// Store account flag in browser storage
storeAccountFlag()
// Store public key separately for quick access
await setPublicKeys(keyPair.publicKey, keyPair.npub)
return {
recoveryPhrase,
npub: keyPair.npub,
publicKey: keyPair.publicKey,
}
}
/**
* Check if an account exists (encrypted key is stored)
*/
async accountExists(): Promise<boolean> {
try {
// Check both the flag and the actual stored key
if (!hasAccountFlag()) {
return false
}
const encrypted = await getEncryptedKey()
return encrypted !== null
} catch {
return false
}
}
/**
* Get the public key and npub if account exists
*/
async getPublicKeys(): Promise<{ publicKey: string; npub: string } | null> {
return await getStoredPublicKeys()
}
/**
* Decrypt and retrieve the private key using recovery phrase
*/
async unlockAccount(recoveryPhrase: string[]): Promise<{
privateKey: string
publicKey: string
npub: string
}> {
// Get encrypted nsec from IndexedDB
const encryptedNsec = await getEncryptedKey()
if (!encryptedNsec) {
throw new Error('No encrypted key found. Please create an account first.')
}
// Derive key from recovery phrase
const derivedKey = await deriveKeyFromPhrase(recoveryPhrase)
// Decrypt the private key
const privateKeyHex = await decryptNsec(derivedKey, encryptedNsec)
// Verify by computing public key
const publicKeyHex = getPublicKey(privateKeyHex)
const npub = nip19.npubEncode(publicKeyHex)
return {
privateKey: privateKeyHex,
publicKey: publicKeyHex,
npub,
}
}
/**
* Delete the account (remove all stored keys)
*/
async deleteAccount(): Promise<void> {
await deleteStoredKeys()
removeAccountFlag()
}
}
export const keyManagementService = new KeyManagementService()

View File

@ -0,0 +1,115 @@
export interface EncryptedPayload {
iv: string
ciphertext: string
}
const SALT_LENGTH = 32
const PBKDF2_ITERATIONS = 100000
const PBKDF2_HASH = 'SHA-256'
const KEY_LENGTH = 32
/**
* Derive an encryption key from a recovery phrase using PBKDF2
*/
export async function deriveKeyFromPhrase(phrase: string[]): Promise<CryptoKey> {
const phraseString = phrase.join(' ')
const encoder = new TextEncoder()
const password = encoder.encode(phraseString)
// Generate a deterministic salt from the phrase itself
// This ensures the same phrase always generates the same key
const saltBuffer = await crypto.subtle.digest('SHA-256', password)
const saltArray = new Uint8Array(saltBuffer)
const salt = saltArray.slice(0, SALT_LENGTH)
// Import password as key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
password,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
)
// Derive key using PBKDF2
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: PBKDF2_ITERATIONS,
hash: PBKDF2_HASH,
},
keyMaterial,
{ name: 'AES-GCM', length: KEY_LENGTH * 8 },
false,
['encrypt', 'decrypt']
)
return derivedKey
}
/**
* Encrypt nsec with derived key
*/
export async function encryptNsec(derivedKey: CryptoKey, nsecHex: string): Promise<EncryptedPayload> {
const encoder = new TextEncoder()
const data = encoder.encode(nsecHex)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
derivedKey,
data
)
const encryptedArray = new Uint8Array(encrypted)
// Convert to base64 for storage
function toBase64(bytes: Uint8Array): string {
let binary = ''
bytes.forEach((b) => {
binary += String.fromCharCode(b)
})
return btoa(binary)
}
return {
iv: toBase64(iv),
ciphertext: toBase64(encryptedArray),
}
}
/**
* Decrypt nsec with derived key
*/
export async function decryptNsec(derivedKey: CryptoKey, payload: EncryptedPayload): Promise<string> {
function fromBase64(value: string): Uint8Array {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
const iv = fromBase64(payload.iv)
const ciphertext = fromBase64(payload.ciphertext)
// Ensure iv and ciphertext are proper ArrayBuffer views
const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength)
const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength)
ivView.set(iv)
const cipherBuffer = ciphertext.buffer instanceof ArrayBuffer ? ciphertext.buffer : new ArrayBuffer(ciphertext.byteLength)
const cipherView = new Uint8Array(cipherBuffer, 0, ciphertext.byteLength)
cipherView.set(ciphertext)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivView },
derivedKey,
cipherView
)
const decoder = new TextDecoder()
return decoder.decode(decrypted)
}

View File

@ -0,0 +1,36 @@
/**
* Word list for generating 4-word recovery phrases
* Using common French words for better user experience
*/
const WORD_LIST = [
'abeille', 'arbre', 'avion', 'bateau', 'café', 'chaton', 'ciel', 'cœur', 'diamant', 'étoile',
'fleur', 'forêt', 'guitare', 'jardin', 'livre', 'lune', 'miel', 'montagne', 'océan', 'papillon',
'piano', 'plage', 'plume', 'rivière', 'soleil', 'tigre', 'voiture', 'vague', 'vent', 'éclair',
'arc', 'banane', 'canard', 'carotte', 'cerise', 'cochon', 'crocodile', 'éléphant', 'grenouille', 'hibou',
'kiwi', 'lapin', 'légume', 'loup', 'mouton', 'orange', 'panda', 'pomme', 'renard', 'serpent',
'tigre', 'tomate', 'tortue', 'vache', 'zèbre', 'balle', 'ballon', 'bateau', 'camion', 'crayon',
'livre', 'maison', 'table', 'chaise', 'fenêtre', 'porte', 'lampe', 'clé', 'roue', 'arbre'
]
/**
* Generate a random 4-word recovery phrase
*/
export function generateRecoveryPhrase(): string[] {
const words: string[] = []
const random = crypto.getRandomValues(new Uint32Array(4))
for (let i = 0; i < 4; i += 1) {
const randomValue = random[i]
if (randomValue === undefined) {
throw new Error('Failed to generate random value')
}
const index = randomValue % WORD_LIST.length
const word = WORD_LIST[index]
if (word === undefined) {
throw new Error('Invalid word index')
}
words.push(word)
}
return words
}

View File

@ -0,0 +1,62 @@
import type { EncryptedPayload } from './keyManagementEncryption'
import { storageService } from './storage/indexedDB'
export const KEY_STORAGE_KEY = 'nostr_encrypted_key'
/**
* Store account identifier in browser storage
* This is just a flag to indicate that an account exists
*/
export function storeAccountFlag(): void {
if (typeof window === 'undefined' || !window.localStorage) {
throw new Error('localStorage not available')
}
localStorage.setItem('nostr_account_exists', 'true')
}
/**
* Check if account flag exists
*/
export function hasAccountFlag(): boolean {
if (typeof window === 'undefined' || !window.localStorage) {
return false
}
return localStorage.getItem('nostr_account_exists') === 'true'
}
/**
* Remove account flag from browser storage
*/
export function removeAccountFlag(): void {
if (typeof window !== 'undefined' && window.localStorage) {
localStorage.removeItem('nostr_account_exists')
}
}
export async function getEncryptedKey(): Promise<EncryptedPayload | null> {
return await storageService.get<EncryptedPayload>(KEY_STORAGE_KEY, 'nostr_key_storage')
}
export async function setEncryptedKey(encryptedNsec: EncryptedPayload): Promise<void> {
await storageService.set(KEY_STORAGE_KEY, encryptedNsec, 'nostr_key_storage')
}
export async function getPublicKeys(): Promise<{ publicKey: string; npub: string } | null> {
try {
const stored = await storageService.get<{ publicKey: string; npub: string }>('nostr_public_key', 'nostr_key_storage')
return stored
} catch {
return null
}
}
export async function setPublicKeys(publicKey: string, npub: string): Promise<void> {
await storageService.set('nostr_public_key', { publicKey, npub }, 'nostr_key_storage')
}
export async function deleteStoredKeys(): Promise<void> {
await storageService.delete(KEY_STORAGE_KEY)
await storageService.delete('nostr_public_key')
}

View File

@ -1,36 +1,14 @@
import { calculateSponsoringSplit } from './platformCommissions'
import { PLATFORM_BITCOIN_ADDRESS } from './platformConfig'
import type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
import { getTransaction } from './mempoolSpaceApi'
import { verifySponsoringTransaction } from './mempoolSpaceVerification'
import { waitForConfirmation } from './mempoolSpaceConfirmation'
const MEMPOOL_API_BASE = 'https://mempool.space/api'
export interface MempoolTransaction {
txid: string
vout: Array<{
value: number // in sats
scriptpubkey_address: string
}>
status: {
confirmed: boolean
block_height?: number
block_hash?: string
}
}
export interface TransactionVerificationResult {
valid: boolean
confirmed: boolean
confirmations: number
authorOutput?: {
address: string
amount: number
}
platformOutput?: {
address: string
amount: number
}
error?: string | undefined
}
export type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
/**
* Mempool.space API service
* Used to verify Bitcoin mainnet transactions for sponsoring payments
*/
/**
* Mempool.space API service
* Used to verify Bitcoin mainnet transactions for sponsoring payments
@ -40,27 +18,7 @@ export class MempoolSpaceService {
* Fetch transaction from mempool.space
*/
async getTransaction(txid: string): Promise<MempoolTransaction | null> {
try {
const response = await fetch(`${MEMPOOL_API_BASE}/tx/${txid}`)
if (!response.ok) {
if (response.status === 404) {
console.warn('Transaction not found on mempool.space', { txid })
return null
}
throw new Error(`Failed to fetch transaction: ${response.status} ${response.statusText}`)
}
const transaction = await response.json() as MempoolTransaction
return transaction
} catch (error) {
console.error('Error fetching transaction from mempool.space', {
txid,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
return null
}
return await getTransaction(txid)
}
/**
@ -71,115 +29,7 @@ export class MempoolSpaceService {
txid: string,
authorMainnetAddress: string
): Promise<TransactionVerificationResult> {
try {
const transaction = await this.getTransaction(txid)
if (!transaction) {
return {
valid: false,
confirmed: false,
confirmations: 0,
error: 'Transaction not found',
}
}
const split = calculateSponsoringSplit()
const expectedAuthorAmount = split.authorSats
const expectedPlatformAmount = split.platformSats
// Find outputs matching expected addresses and amounts
const authorOutput = transaction.vout.find(
(output) =>
output.scriptpubkey_address === authorMainnetAddress &&
output.value === expectedAuthorAmount
)
const platformOutput = transaction.vout.find(
(output) =>
output.scriptpubkey_address === PLATFORM_BITCOIN_ADDRESS &&
output.value === expectedPlatformAmount
)
const valid = Boolean(authorOutput && platformOutput)
const confirmed = transaction.status.confirmed
const confirmations = confirmed && transaction.status.block_height
? await this.getConfirmations(transaction.status.block_height)
: 0
if (!valid) {
console.error('Transaction verification failed', {
txid,
authorAddress: authorMainnetAddress,
platformAddress: PLATFORM_BITCOIN_ADDRESS,
expectedAuthorAmount,
expectedPlatformAmount,
actualOutputs: transaction.vout.map((o) => ({
address: o.scriptpubkey_address,
amount: o.value,
})),
timestamp: new Date().toISOString(),
})
}
const result: TransactionVerificationResult = {
valid,
confirmed,
confirmations,
}
if (!valid) {
result.error = 'Transaction outputs do not match expected split'
}
if (authorOutput) {
result.authorOutput = {
address: authorOutput.scriptpubkey_address,
amount: authorOutput.value,
}
}
if (platformOutput) {
result.platformOutput = {
address: platformOutput.scriptpubkey_address,
amount: platformOutput.value,
}
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error verifying sponsoring transaction', {
txid,
authorAddress: authorMainnetAddress,
error: errorMessage,
timestamp: new Date().toISOString(),
})
return {
valid: false,
confirmed: false,
confirmations: 0,
error: errorMessage,
}
}
}
/**
* Get current block height and calculate confirmations
*/
private async getConfirmations(blockHeight: number): Promise<number> {
try {
const response = await fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`)
if (!response.ok) {
return 0
}
const currentHeight = await response.json() as number
return Math.max(0, currentHeight - blockHeight + 1)
} catch (error) {
console.error('Error getting current block height', {
error: error instanceof Error ? error.message : 'Unknown error',
})
return 0
}
return await verifySponsoringTransaction(txid, authorMainnetAddress)
}
/**
@ -191,42 +41,7 @@ export class MempoolSpaceService {
timeout: number = 600000, // 10 minutes
interval: number = 10000 // 10 seconds
): Promise<TransactionVerificationResult | null> {
const startTime = Date.now()
return new Promise((resolve) => {
const checkConfirmation = async () => {
if (Date.now() - startTime > timeout) {
resolve(null)
return
}
// Get author address from transaction (first output that's not platform)
const transaction = await this.getTransaction(txid)
if (!transaction) {
setTimeout(checkConfirmation, interval)
return
}
const authorOutput = transaction.vout.find(
(output) => output.scriptpubkey_address !== PLATFORM_BITCOIN_ADDRESS
)
if (!authorOutput) {
setTimeout(checkConfirmation, interval)
return
}
const result = await this.verifySponsoringTransaction(txid, authorOutput.scriptpubkey_address)
if (result.confirmed && result.valid) {
resolve(result)
} else {
setTimeout(checkConfirmation, interval)
}
}
checkConfirmation()
})
return await waitForConfirmation(txid, timeout, interval)
}
}

43
lib/mempoolSpaceApi.ts Normal file
View File

@ -0,0 +1,43 @@
import type { MempoolTransaction } from './mempoolSpaceTypes'
const MEMPOOL_API_BASE = 'https://mempool.space/api'
export async function getTransaction(txid: string): Promise<MempoolTransaction | null> {
try {
const response = await fetch(`${MEMPOOL_API_BASE}/tx/${txid}`)
if (!response.ok) {
if (response.status === 404) {
console.warn('Transaction not found on mempool.space', { txid })
return null
}
throw new Error(`Failed to fetch transaction: ${response.status} ${response.statusText}`)
}
const transaction = await response.json() as MempoolTransaction
return transaction
} catch (error) {
console.error('Error fetching transaction from mempool.space', {
txid,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
return null
}
}
export async function getConfirmations(blockHeight: number): Promise<number> {
try {
const response = await fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`)
if (!response.ok) {
return 0
}
const currentHeight = await response.json() as number
return Math.max(0, currentHeight - blockHeight + 1)
} catch (error) {
console.error('Error getting current block height', {
error: error instanceof Error ? error.message : 'Unknown error',
})
return 0
}
}

View File

@ -0,0 +1,61 @@
import { PLATFORM_BITCOIN_ADDRESS } from './platformConfig'
import type { TransactionVerificationResult } from './mempoolSpaceTypes'
import { getTransaction } from './mempoolSpaceApi'
import { verifySponsoringTransaction } from './mempoolSpaceVerification'
export function scheduleNextCheck(checkConfirmation: () => void, interval: number) {
setTimeout(() => {
void checkConfirmation()
}, interval)
}
export async function checkTransactionStatus(
txid: string,
startTime: number,
timeout: number,
interval: number,
resolve: (value: TransactionVerificationResult | null) => void,
checkConfirmation: () => void
): Promise<void> {
if (Date.now() - startTime > timeout) {
resolve(null)
return
}
const transaction = await getTransaction(txid)
if (!transaction) {
scheduleNextCheck(checkConfirmation, interval)
return
}
const authorOutput = transaction.vout.find(
(output) => output.scriptpubkey_address !== PLATFORM_BITCOIN_ADDRESS
)
if (!authorOutput) {
scheduleNextCheck(checkConfirmation, interval)
return
}
const result = await verifySponsoringTransaction(txid, authorOutput.scriptpubkey_address)
if (result.confirmed && result.valid) {
resolve(result)
} else {
scheduleNextCheck(checkConfirmation, interval)
}
}
export async function waitForConfirmation(
txid: string,
timeout: number = 600000, // 10 minutes
interval: number = 10000 // 10 seconds
): Promise<TransactionVerificationResult | null> {
const startTime = Date.now()
return new Promise((resolve) => {
const checkConfirmation = async () => {
await checkTransactionStatus(txid, startTime, timeout, interval, resolve, checkConfirmation)
}
void checkConfirmation()
})
}

27
lib/mempoolSpaceTypes.ts Normal file
View File

@ -0,0 +1,27 @@
export interface MempoolTransaction {
txid: string
vout: Array<{
value: number // in sats
scriptpubkey_address: string
}>
status: {
confirmed: boolean
block_height?: number
block_hash?: string
}
}
export interface TransactionVerificationResult {
valid: boolean
confirmed: boolean
confirmations: number
authorOutput?: {
address: string
amount: number
}
platformOutput?: {
address: string
amount: number
}
error?: string | undefined
}

View File

@ -0,0 +1,169 @@
import { calculateSponsoringSplit } from './platformCommissions'
import { PLATFORM_BITCOIN_ADDRESS } from './platformConfig'
import type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
import { getTransaction, getConfirmations } from './mempoolSpaceApi'
export function findTransactionOutputs(
transaction: MempoolTransaction,
authorMainnetAddress: string,
expectedAuthorAmount: number,
expectedPlatformAmount: number
): { authorOutput?: MempoolTransaction['vout'][0]; platformOutput?: MempoolTransaction['vout'][0] } {
const authorOutput = transaction.vout.find(
(output) =>
output.scriptpubkey_address === authorMainnetAddress &&
output.value === expectedAuthorAmount
)
const platformOutput = transaction.vout.find(
(output) =>
output.scriptpubkey_address === PLATFORM_BITCOIN_ADDRESS &&
output.value === expectedPlatformAmount
)
const result: { authorOutput?: MempoolTransaction['vout'][0]; platformOutput?: MempoolTransaction['vout'][0] } = {}
if (authorOutput) {
result.authorOutput = authorOutput
}
if (platformOutput) {
result.platformOutput = platformOutput
}
return result
}
export function buildVerificationResult(
valid: boolean,
confirmed: boolean,
confirmations: number,
authorOutput?: MempoolTransaction['vout'][0],
platformOutput?: MempoolTransaction['vout'][0]
): TransactionVerificationResult {
const result: TransactionVerificationResult = {
valid,
confirmed,
confirmations,
}
if (!valid) {
result.error = 'Transaction outputs do not match expected split'
}
if (authorOutput) {
result.authorOutput = {
address: authorOutput.scriptpubkey_address,
amount: authorOutput.value,
}
}
if (platformOutput) {
result.platformOutput = {
address: platformOutput.scriptpubkey_address,
amount: platformOutput.value,
}
}
return result
}
export async function validateTransactionOutputs(
transaction: MempoolTransaction,
authorMainnetAddress: string,
split: { authorSats: number; platformSats: number }
): Promise<{ valid: boolean; confirmed: boolean; confirmations: number; authorOutput?: MempoolTransaction['vout'][0]; platformOutput?: MempoolTransaction['vout'][0] }> {
const { authorOutput, platformOutput } = findTransactionOutputs(
transaction,
authorMainnetAddress,
split.authorSats,
split.platformSats
)
const valid = Boolean(authorOutput && platformOutput)
const confirmed = transaction.status.confirmed
const confirmations = confirmed && transaction.status.block_height
? await getConfirmations(transaction.status.block_height)
: 0
const result: { valid: boolean; confirmed: boolean; confirmations: number; authorOutput?: MempoolTransaction['vout'][0]; platformOutput?: MempoolTransaction['vout'][0] } = {
valid,
confirmed,
confirmations,
}
if (authorOutput) {
result.authorOutput = authorOutput
}
if (platformOutput) {
result.platformOutput = platformOutput
}
return result
}
export function logVerificationFailure(
txid: string,
authorMainnetAddress: string,
split: { authorSats: number; platformSats: number },
transaction: MempoolTransaction
): void {
console.error('Transaction verification failed', {
txid,
authorAddress: authorMainnetAddress,
platformAddress: PLATFORM_BITCOIN_ADDRESS,
expectedAuthorAmount: split.authorSats,
expectedPlatformAmount: split.platformSats,
actualOutputs: transaction.vout.map((o) => ({
address: o.scriptpubkey_address,
amount: o.value,
})),
timestamp: new Date().toISOString(),
})
}
export function handleVerificationError(txid: string, authorMainnetAddress: string, error: unknown): TransactionVerificationResult {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error verifying sponsoring transaction', {
txid,
authorAddress: authorMainnetAddress,
error: errorMessage,
timestamp: new Date().toISOString(),
})
return {
valid: false,
confirmed: false,
confirmations: 0,
error: errorMessage,
}
}
export async function verifySponsoringTransaction(
txid: string,
authorMainnetAddress: string
): Promise<TransactionVerificationResult> {
try {
const transaction = await getTransaction(txid)
if (!transaction) {
return {
valid: false,
confirmed: false,
confirmations: 0,
error: 'Transaction not found',
}
}
const split = calculateSponsoringSplit()
const validation = await validateTransactionOutputs(transaction, authorMainnetAddress, split)
if (!validation.valid) {
logVerificationFailure(txid, authorMainnetAddress, split, transaction)
}
return buildVerificationResult(
validation.valid,
validation.confirmed,
validation.confirmations,
validation.authorOutput,
validation.platformOutput
)
} catch (error) {
return handleVerificationError(txid, authorMainnetAddress, error)
}
}

View File

@ -1,4 +1,5 @@
import type { MediaRef } from '@/types/nostr'
import { getPrimaryNip95Api } from './config'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_VIDEO_BYTES = 45 * 1024 * 1024
@ -36,10 +37,10 @@ export async function uploadNip95Media(file: File): Promise<MediaRef> {
assertBrowser()
const mediaType = validateFile(file)
const endpoint = process.env.NEXT_PUBLIC_NIP95_UPLOAD_URL
const endpoint = await getPrimaryNip95Api()
if (!endpoint) {
throw new Error(
'NIP-95 upload endpoint is not configured. Please set NEXT_PUBLIC_NIP95_UPLOAD_URL environment variable. See README.md for setup instructions.'
'NIP-95 upload endpoint is not configured. Please configure a NIP-95 API endpoint in the application settings.'
)
}

View File

@ -9,8 +9,8 @@ import {
} from './nostrPrivateMessages'
import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification'
import { subscribeWithTimeout } from './nostrSubscription'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
import { getPrimaryRelay, getPrimaryRelaySync } from './config'
import { buildTagFilter } from './nostrTagSystem'
class NostrService {
private pool: SimplePool | null = null
@ -77,7 +77,8 @@ class NostrService {
} as Event
try {
const pubs = this.pool.publish([RELAY_URL], event)
const relayUrl = await getPrimaryRelay()
const pubs = this.pool.publish([relayUrl], event)
await Promise.all(pubs)
return event
} catch (e) {
@ -85,6 +86,19 @@ class NostrService {
}
}
private createArticleSubscription(pool: SimplePoolWithSub, limit: number) {
const filters = [
{
...buildTagFilter({
type: 'publication',
}),
limit,
},
]
const relayUrl = getPrimaryRelaySync()
return pool.sub([relayUrl], filters)
}
subscribeToArticles(callback: (article: Article) => void, limit: number = 100): () => void {
if (typeof window === 'undefined') {
throw new Error('Cannot subscribe on server side')
@ -98,20 +112,8 @@ class NostrService {
throw new Error('Pool not initialized')
}
// Use new tag system to filter publications
// Import synchronously since this is not async
const { buildTagFilter } = require('./nostrTagSystem')
const filters = [
{
...buildTagFilter({
type: 'publication',
}),
limit,
},
]
const pool = this.pool as SimplePoolWithSub
const sub = pool.sub([RELAY_URL], filters)
const sub = this.createArticleSubscription(pool, limit)
sub.on('event', (event: Event) => {
try {
@ -152,39 +154,32 @@ class NostrService {
* then retrieves the decryption key from private messages,
* and finally decrypts the content
*/
private async retrieveDecryptionKey(eventId: string, authorPubkey: string): Promise<{ key: string; iv: string } | null> {
if (!this.privateKey || !this.pool || !this.publicKey) {
return null
}
return await getDecryptionKey(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey)
}
async getDecryptedArticleContent(eventId: string, authorPubkey: string): Promise<string | null> {
if (!this.privateKey || !this.pool || !this.publicKey) {
throw new Error('Private key not set or pool not initialized')
}
try {
// Get the raw event to retrieve the encrypted content
const event = await this.getEventById(eventId)
if (!event) {
console.error('Event not found', { eventId, authorPubkey })
return null
}
const encryptedContent = event.content
// Try to get the decryption key from private messages
const decryptionKey = await getDecryptionKey(
this.pool,
eventId,
authorPubkey,
this.privateKey,
this.publicKey
)
const decryptionKey = await this.retrieveDecryptionKey(eventId, authorPubkey)
if (!decryptionKey) {
console.warn('Decryption key not found in private messages', { eventId, authorPubkey })
return null
}
// Decrypt the content using the key
const decryptedContent = await decryptArticleContentWithKey(encryptedContent, decryptionKey)
return decryptedContent
return await decryptArticleContentWithKey(event.content, decryptionKey)
} catch (error) {
console.error('Error decrypting article content', {
eventId,
@ -239,6 +234,8 @@ class NostrService {
throw new Error('Private key not set')
}
const relayUrl = await getPrimaryRelay()
const zapRequest: EventTemplate = {
kind: 9734, // Zap request
created_at: Math.floor(Date.now() / 1000),
@ -246,7 +243,7 @@ class NostrService {
['p', targetPubkey],
['e', targetEventId],
['amount', amount.toString()],
['relays', RELAY_URL],
['relays', relayUrl],
],
content: '',
}

View File

@ -1,9 +1,10 @@
import { nostrService } from './nostr'
import { keyManagementService } from './keyManagement'
import type { NostrConnectState } from '@/types/nostr'
/**
* Nostr authentication service using Alby (NIP-07)
* Alby exposes window.nostr API for Nostr authentication and signing
* Nostr authentication service using local key management
* Keys are stored encrypted in IndexedDB and decrypted using recovery phrase
*/
export class NostrAuthService {
private state: NostrConnectState = {
@ -13,6 +14,7 @@ export class NostrAuthService {
}
private listeners: Set<(state: NostrConnectState) => void> = new Set()
private unlockedPrivateKey: string | null = null
constructor() {
if (typeof window !== 'undefined') {
@ -34,55 +36,128 @@ export class NostrAuthService {
}
/**
* Check if Alby (window.nostr) is available
* Check if account exists
*/
isAvailable(): boolean {
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'
async accountExists(): Promise<boolean> {
return keyManagementService.accountExists()
}
/**
* Connect using Alby (NIP-07)
* Create a new account (generate or import key)
* Returns recovery phrase and npub
*/
async connect(): Promise<void> {
if (!this.isAvailable()) {
throw new Error('Alby extension not available. Please install Alby browser extension.')
}
async createAccount(privateKey?: string): Promise<{
recoveryPhrase: string[]
npub: string
publicKey: string
}> {
const result = await keyManagementService.createAccount(privateKey)
if (!window.nostr) {
throw new Error('window.nostr is not available. Please ensure Alby extension is installed and enabled.')
// Set public key immediately
this.state = {
connected: false,
pubkey: result.publicKey,
profile: null,
}
nostrService.setPublicKey(result.publicKey)
this.saveStateToStorage()
this.notifyListeners()
return result
}
/**
* Unlock account using recovery phrase
*/
async unlockAccount(recoveryPhrase: string[]): Promise<void> {
try {
const pubkey = await window.nostr.getPublicKey()
if (!pubkey) {
throw new Error('Failed to get public key from Alby')
}
const keys = await keyManagementService.unlockAccount(recoveryPhrase)
this.unlockedPrivateKey = keys.privateKey
this.state = {
connected: true,
pubkey,
pubkey: keys.publicKey,
profile: null,
}
nostrService.setPublicKey(pubkey)
nostrService.setPublicKey(keys.publicKey)
nostrService.setPrivateKey(keys.privateKey)
this.saveStateToStorage()
this.notifyListeners()
void this.loadProfile()
} catch (e) {
console.error('Error connecting with Alby:', e)
throw new Error(`Failed to connect with Alby: ${e instanceof Error ? e.message : 'Unknown error'}`)
console.error('Error unlocking account:', e)
throw new Error(`Failed to unlock account: ${e instanceof Error ? e.message : 'Unknown error'}`)
}
}
/**
* Connect using existing stored keys (if already unlocked)
* This is called when the app loads and keys are already available
*/
async connect(): Promise<void> {
// Check if account exists
const exists = await keyManagementService.accountExists()
if (!exists) {
throw new Error('No account found. Please create an account first.')
}
// Try to get public keys
const publicKeys = await keyManagementService.getPublicKeys()
if (!publicKeys) {
throw new Error('Account exists but public keys not found')
}
// Set public key but don't unlock private key yet
// Private key will be unlocked when user provides recovery phrase
this.state = {
connected: false,
pubkey: publicKeys.publicKey,
profile: null,
}
nostrService.setPublicKey(publicKeys.publicKey)
this.saveStateToStorage()
this.notifyListeners()
void this.loadProfile()
}
/**
* Get the private key if unlocked
*/
getPrivateKey(): string | null {
return this.unlockedPrivateKey
}
/**
* Check if private key is unlocked
*/
isUnlocked(): boolean {
return this.unlockedPrivateKey !== null
}
disconnect(): void {
this.unlockedPrivateKey = null
this.state = {
connected: false,
pubkey: null,
profile: null,
}
// Clear keys from nostrService - the service stores keys internally, we just clear our reference
// The service will continue to work but won't have access to the keys
nostrService.setPrivateKey('')
nostrService.setPublicKey('')
this.saveStateToStorage()
this.notifyListeners()
}
/**
* Delete account (remove all stored keys)
*/
async deleteAccount(): Promise<void> {
await keyManagementService.deleteAccount()
this.disconnect()
}
private async loadProfile(): Promise<void> {
if (!this.state.pubkey) {
return
@ -121,6 +196,7 @@ export class NostrAuthService {
if (this.state.pubkey) {
nostrService.setPublicKey(this.state.pubkey)
}
// Note: private key is not stored, it must be unlocked with recovery phrase
}
} catch (e) {
console.error('Error loading state from storage:', e)
@ -129,7 +205,13 @@ export class NostrAuthService {
private saveStateToStorage(): void {
try {
localStorage.setItem('nostr_auth_state', JSON.stringify(this.state))
// Only save public information, never private keys
const stateToSave = {
connected: this.state.connected,
pubkey: this.state.pubkey,
profile: this.state.profile,
}
localStorage.setItem('nostr_auth_state', JSON.stringify(stateToSave))
} catch (e) {
console.error('Error saving state to storage:', e)
}

View File

@ -99,20 +99,20 @@ function buildArticle(event: Event, tags: ReturnType<typeof extractTagsFromEvent
return {
id: tags.id ?? event.id,
pubkey: event.pubkey,
title: (tags.title as string | undefined) ?? 'Untitled',
title: tags.title ?? 'Untitled',
preview,
content: '',
createdAt: event.created_at,
zapAmount: (tags.zapAmount as number | undefined) ?? 800,
zapAmount: tags.zapAmount ?? 800,
paid: false,
...(tags.invoice ? { invoice: tags.invoice as string } : {}),
...(tags.paymentHash ? { paymentHash: tags.paymentHash as string } : {}),
...(tags.invoice ? { invoice: tags.invoice } : {}),
...(tags.paymentHash ? { paymentHash: tags.paymentHash } : {}),
...(category ? { category } : {}),
...(isPresentation ? { isPresentation: true } : {}),
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress as string } : {}),
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring as number } : {}),
...(tags.seriesId ? { seriesId: tags.seriesId as string } : {}),
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl as string } : {}),
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress } : {}),
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring } : {}),
...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
...(tags.type === 'publication' ? { kindType: 'article' as KindType } : tags.type === 'author' ? { kindType: 'article' as KindType } : {}),
}
}

View File

@ -1,8 +1,7 @@
import { Event, nip04 } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
import { decryptArticleContent, type DecryptionKey } from './articleEncryption'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
import { getPrimaryRelaySync } from './config'
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string) {
return [
@ -39,7 +38,8 @@ export function getPrivateContent(
return new Promise((resolve) => {
let resolved = false
const sub = pool.sub([RELAY_URL], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
const relayUrl = getPrimaryRelaySync()
const sub = pool.sub([relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
const finalize = (result: string | null) => {
if (resolved) {
@ -70,6 +70,37 @@ export function getPrivateContent(
* Get decryption key for an article from private messages
* Returns the decryption key and IV if found
*/
function parseDecryptionKey(decryptedContent: string): DecryptionKey | null {
try {
const keyData = JSON.parse(decryptedContent) as DecryptionKey
if (keyData.key && keyData.iv) {
return keyData
}
} catch {
// If parsing fails, it might be old format (full content)
}
return null
}
function handleDecryptionKeyEvent(
event: Event,
recipientPrivateKey: string,
finalize: (result: DecryptionKey | null) => void
): void {
void decryptContent(recipientPrivateKey, event)
.then((decryptedContent) => {
if (decryptedContent) {
const keyData = parseDecryptionKey(decryptedContent)
if (keyData) {
finalize(keyData)
}
}
})
.catch((e) => {
console.error('Error decrypting decryption key:', e)
})
}
export async function getDecryptionKey(
pool: SimplePool,
eventId: string,
@ -83,7 +114,8 @@ export async function getDecryptionKey(
return new Promise((resolve) => {
let resolved = false
const sub = pool.sub([RELAY_URL], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
const relayUrl = getPrimaryRelaySync()
const sub = pool.sub([relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
const finalize = (result: DecryptionKey | null) => {
if (resolved) {
@ -94,25 +126,8 @@ export async function getDecryptionKey(
resolve(result)
}
sub.on('event', async (event: Event) => {
try {
const decryptedContent = await decryptContent(recipientPrivateKey, event)
if (decryptedContent) {
try {
// Try to parse as decryption key (new format)
const keyData = JSON.parse(decryptedContent) as DecryptionKey
if (keyData.key && keyData.iv) {
finalize(keyData)
return
}
} catch {
// If parsing fails, it might be old format (full content)
// Return null to indicate we need to use the old method
}
}
} catch (e) {
console.error('Error decrypting decryption key:', e)
}
sub.on('event', (event: Event) => {
handleDecryptionKeyEvent(event, recipientPrivateKey, finalize)
})
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)

View File

@ -11,52 +11,58 @@ export class NostrRemoteSigner {
/**
* Sign an event template using Alby (window.nostr)
*/
private buildUnsignedEvent(eventTemplate: EventTemplate, pubkey: string): EventTemplate & { pubkey: string } {
return {
pubkey,
...eventTemplate,
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
}
}
private async signWithAlby(unsignedEvent: EventTemplate & { pubkey: string }): Promise<Event | null> {
if (typeof window === 'undefined' || !window.nostr) {
return null
}
try {
const signedEvent = await window.nostr.signEvent({
kind: unsignedEvent.kind,
created_at: unsignedEvent.created_at,
tags: unsignedEvent.tags,
content: unsignedEvent.content,
})
return signedEvent as Event
} catch (e) {
console.error('Error signing with Alby:', e)
throw new Error('Failed to sign event with Alby extension')
}
}
private signWithPrivateKey(unsignedEvent: EventTemplate & { pubkey: string }): Event {
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error('Alby extension required for signing. Please install and connect Alby browser extension.')
}
const eventId = getEventHash(unsignedEvent)
return {
...unsignedEvent,
id: eventId,
sig: signEvent(unsignedEvent, privateKey),
} as Event
}
async signEvent(eventTemplate: EventTemplate): Promise<Event | null> {
// Get the event hash first
const pubkey = nostrService.getPublicKey()
if (!pubkey) {
throw new Error('Public key required for signing. Please connect with Alby.')
}
const unsignedEvent = {
pubkey,
...eventTemplate,
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
}
const eventId = getEventHash(unsignedEvent)
// Use Alby (window.nostr) for signing
if (typeof window !== 'undefined' && window.nostr) {
try {
const signedEvent = await window.nostr.signEvent({
kind: unsignedEvent.kind,
created_at: unsignedEvent.created_at,
tags: unsignedEvent.tags,
content: unsignedEvent.content,
})
return signedEvent as Event
} catch (e) {
console.error('Error signing with Alby:', e)
throw new Error('Failed to sign event with Alby extension')
}
const unsignedEvent = this.buildUnsignedEvent(eventTemplate, pubkey)
const albySigned = await this.signWithAlby(unsignedEvent)
if (albySigned) {
return albySigned
}
// Fallback to private key signing (should not happen if Alby is properly connected)
const privateKey = nostrService.getPrivateKey()
if (!privateKey) {
throw new Error(
'Alby extension required for signing. ' +
'Please install and connect Alby browser extension.'
)
}
const event = {
...unsignedEvent,
id: eventId,
sig: signEvent(unsignedEvent, privateKey),
} as Event
return event
return this.signWithPrivateKey(unsignedEvent)
}
/**

View File

@ -1,7 +1,6 @@
import type { Event, Filter } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
import { getPrimaryRelaySync } from './config'
/**
* Subscribe to events with timeout
@ -14,7 +13,8 @@ export function subscribeWithTimeout<T>(
): Promise<T | null> {
return new Promise((resolve) => {
const resolved = { value: false }
const sub = pool.sub([RELAY_URL], filters)
const relayUrl = getPrimaryRelaySync()
const sub = pool.sub([relayUrl], filters)
let timeoutId: NodeJS.Timeout | null = null
const cleanup = () => {

View File

@ -1,274 +1,4 @@
/**
* New tag system based on:
* - #paywall: for paid publications
* - #sciencefiction or #research: for category
* - #author, #series, #publication, #quote: for type
* - #id_<id>: for identifier
* - #payment (optional): for payment notes
*
* Everything is a Nostr note (kind 1)
* All tags are in English
*/
export type TagType = 'author' | 'series' | 'publication' | 'quote'
export type TagCategory = 'sciencefiction' | 'research'
export interface BaseTags {
type: TagType
category: TagCategory
id: string
paywall?: boolean
payment?: boolean
}
export interface AuthorTags extends BaseTags {
type: 'author'
title: string
preview?: string
mainnetAddress?: string
totalSponsoring?: number
pictureUrl?: string
}
export interface SeriesTags extends BaseTags {
type: 'series'
title: string
description: string
preview?: string
coverUrl?: string
}
export interface PublicationTags extends BaseTags {
type: 'publication'
title: string
preview?: string
seriesId?: string
bannerUrl?: string
zapAmount?: number
invoice?: string
paymentHash?: string
encryptedKey?: string
}
export interface QuoteTags extends BaseTags {
type: 'quote'
articleId: string
reviewerPubkey?: string
title?: string
}
/**
* Build tags array from tag object
* Tags format: ['tag_name'] for simple tags, ['tag_name', 'value'] for tags with values
*/
export function buildTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTags): string[][] {
const result: string[][] = []
// Type tag (required) - simple tag without value
result.push([tags.type])
// Category tag (required) - simple tag without value
result.push([tags.category])
// ID tag (required) - tag with value: ['id', '<id>']
result.push(['id', tags.id])
// Paywall tag (optional) - simple tag without value
if (tags.paywall) {
result.push(['paywall'])
}
// Payment tag (optional) - simple tag without value
if (tags.payment) {
result.push(['payment'])
}
// Type-specific tags
if (tags.type === 'author') {
const authorTags = tags as AuthorTags
result.push(['title', authorTags.title])
if (authorTags.preview) {
result.push(['preview', authorTags.preview])
}
if (authorTags.mainnetAddress) {
result.push(['mainnet_address', authorTags.mainnetAddress])
}
if (authorTags.totalSponsoring !== undefined) {
result.push(['total_sponsoring', authorTags.totalSponsoring.toString()])
}
if (authorTags.pictureUrl) {
result.push(['picture', authorTags.pictureUrl])
}
} else if (tags.type === 'series') {
const seriesTags = tags as SeriesTags
result.push(['title', seriesTags.title])
result.push(['description', seriesTags.description])
if (seriesTags.preview) {
result.push(['preview', seriesTags.preview])
}
if (seriesTags.coverUrl) {
result.push(['cover', seriesTags.coverUrl])
}
} else if (tags.type === 'publication') {
const pubTags = tags as PublicationTags
result.push(['title', pubTags.title])
if (pubTags.preview) {
result.push(['preview', pubTags.preview])
}
if (pubTags.seriesId) {
result.push(['series', pubTags.seriesId])
}
if (pubTags.bannerUrl) {
result.push(['banner', pubTags.bannerUrl])
}
if (pubTags.zapAmount) {
result.push(['zap', pubTags.zapAmount.toString()])
}
if (pubTags.invoice) {
result.push(['invoice', pubTags.invoice])
}
if (pubTags.paymentHash) {
result.push(['payment_hash', pubTags.paymentHash])
}
if (pubTags.encryptedKey) {
result.push(['encrypted_key', pubTags.encryptedKey])
}
} else if (tags.type === 'quote') {
const quoteTags = tags as QuoteTags
result.push(['article', quoteTags.articleId])
if (quoteTags.reviewerPubkey) {
result.push(['reviewer', quoteTags.reviewerPubkey])
}
if (quoteTags.title) {
result.push(['title', quoteTags.title])
}
}
return result
}
/**
* Extract tags from event
*/
export function extractTagsFromEvent(event: { tags: string[][] }): {
type?: TagType | undefined
category?: TagCategory | undefined
id?: string | undefined
paywall: boolean
payment: boolean
title?: string | undefined
preview?: string | undefined
description?: string | undefined
mainnetAddress?: string | undefined
totalSponsoring?: number | undefined
seriesId?: string | undefined
coverUrl?: string | undefined
bannerUrl?: string | undefined
zapAmount?: number | undefined
invoice?: string | undefined
paymentHash?: string | undefined
articleId?: string | undefined
reviewerPubkey?: string | undefined
[key: string]: unknown
} {
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
const hasTag = (key: string) => event.tags.some((tag) => tag[0] === key || (tag.length === 1 && tag[0] === key))
const type = event.tags.find((tag) => tag.length === 1 && tag[0] && ['author', 'series', 'publication', 'quote'].includes(tag[0]))?.[0] as TagType | undefined
const category = event.tags.find((tag) => tag.length === 1 && tag[0] && ['sciencefiction', 'research'].includes(tag[0]))?.[0] as TagCategory | undefined
const id = findTag('id')
return {
type,
category,
id,
paywall: hasTag('paywall'),
payment: hasTag('payment'),
// Extract all other tags
title: findTag('title'),
preview: findTag('preview'),
description: findTag('description'),
mainnetAddress: findTag('mainnet_address'),
totalSponsoring: (() => {
const val = findTag('total_sponsoring')
return val ? parseInt(val, 10) : undefined
})(),
pictureUrl: findTag('picture'),
seriesId: findTag('series'),
coverUrl: findTag('cover'),
bannerUrl: findTag('banner'),
zapAmount: (() => {
const val = findTag('zap')
return val ? parseInt(val, 10) : undefined
})(),
invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'),
encryptedKey: findTag('encrypted_key'),
articleId: findTag('article'),
reviewerPubkey: findTag('reviewer'),
}
}
/**
* Build Nostr filter for querying by tags
* Nostr filters use #tag for tag-based filtering
*/
export function buildTagFilter(params: {
type?: TagType
category?: TagCategory
id?: string
paywall?: boolean
payment?: boolean
seriesId?: string
articleId?: string
authorPubkey?: string
}): Record<string, string[] | number[]> {
const filter: Record<string, string[] | number[]> = {
kinds: [1], // All are kind 1 notes
}
// Type tag filter (simple tag without value)
if (params.type) {
filter[`#${params.type}`] = ['']
}
// Category tag filter (simple tag without value)
if (params.category) {
filter[`#${params.category}`] = ['']
}
// ID tag filter (tag with value)
if (params.id) {
filter['#id'] = [params.id]
} else {
// If no ID specified, we still need to ensure the filter structure is valid
// Nostr filters require at least one valid filter property
}
// Paywall tag filter (simple tag without value)
if (params.paywall) {
filter['#paywall'] = ['']
}
// Payment tag filter (simple tag without value)
if (params.payment) {
filter['#payment'] = ['']
}
// Series ID filter (tag with value)
if (params.seriesId) {
filter['#series'] = [params.seriesId]
}
// Article ID filter (tag with value)
if (params.articleId) {
filter['#article'] = [params.articleId]
}
// Author pubkey filter
if (params.authorPubkey) {
filter.authors = [params.authorPubkey]
}
return filter
}
export type { TagType, TagCategory, BaseTags, AuthorTags, SeriesTags, PublicationTags, QuoteTags } from './nostrTagSystemTypes'
export { buildTags } from './nostrTagSystemBuild'
export { extractTagsFromEvent } from './nostrTagSystemExtract'
export { buildTagFilter } from './nostrTagSystemFilter'

View File

@ -0,0 +1,93 @@
import type { AuthorTags, SeriesTags, PublicationTags, QuoteTags } from './nostrTagSystemTypes'
function buildBaseTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTags): string[][] {
const result: string[][] = []
result.push([tags.type])
result.push([tags.category])
result.push(['id', tags.id])
if (tags.paywall) {
result.push(['paywall'])
}
if (tags.payment) {
result.push(['payment'])
}
return result
}
export function buildAuthorTags(authorTags: AuthorTags, result: string[][]): void {
result.push(['title', authorTags.title])
if (authorTags.preview) {
result.push(['preview', authorTags.preview])
}
if (authorTags.mainnetAddress) {
result.push(['mainnet_address', authorTags.mainnetAddress])
}
if (authorTags.totalSponsoring !== undefined) {
result.push(['total_sponsoring', authorTags.totalSponsoring.toString()])
}
if (authorTags.pictureUrl) {
result.push(['picture', authorTags.pictureUrl])
}
}
export function buildSeriesTags(seriesTags: SeriesTags, result: string[][]): void {
result.push(['title', seriesTags.title])
result.push(['description', seriesTags.description])
if (seriesTags.preview) {
result.push(['preview', seriesTags.preview])
}
if (seriesTags.coverUrl) {
result.push(['cover', seriesTags.coverUrl])
}
}
export function buildPublicationTags(pubTags: PublicationTags, result: string[][]): void {
result.push(['title', pubTags.title])
if (pubTags.preview) {
result.push(['preview', pubTags.preview])
}
if (pubTags.seriesId) {
result.push(['series', pubTags.seriesId])
}
if (pubTags.bannerUrl) {
result.push(['banner', pubTags.bannerUrl])
}
if (pubTags.zapAmount) {
result.push(['zap', pubTags.zapAmount.toString()])
}
if (pubTags.invoice) {
result.push(['invoice', pubTags.invoice])
}
if (pubTags.paymentHash) {
result.push(['payment_hash', pubTags.paymentHash])
}
if (pubTags.encryptedKey) {
result.push(['encrypted_key', pubTags.encryptedKey])
}
}
export function buildQuoteTags(quoteTags: QuoteTags, result: string[][]): void {
result.push(['article', quoteTags.articleId])
if (quoteTags.reviewerPubkey) {
result.push(['reviewer', quoteTags.reviewerPubkey])
}
if (quoteTags.title) {
result.push(['title', quoteTags.title])
}
}
export function buildTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTags): string[][] {
const result = buildBaseTags(tags)
if (tags.type === 'author') {
buildAuthorTags(tags as AuthorTags, result)
} else if (tags.type === 'series') {
buildSeriesTags(tags as SeriesTags, result)
} else if (tags.type === 'publication') {
buildPublicationTags(tags as PublicationTags, result)
} else if (tags.type === 'quote') {
buildQuoteTags(tags as QuoteTags, result)
}
return result
}

View File

@ -0,0 +1,75 @@
import type { TagType, TagCategory } from './nostrTagSystemTypes'
export function parseNumericTag(findTag: (key: string) => string | undefined, key: string): number | undefined {
const val = findTag(key)
return val ? parseInt(val, 10) : undefined
}
export function extractTypeAndCategory(event: { tags: string[][] }): { type?: TagType; category?: TagCategory } {
const typeValue = event.tags.find((tag) => tag.length === 1 && tag[0] && ['author', 'series', 'publication', 'quote'].includes(tag[0]))?.[0] as TagType | undefined
const categoryValue = event.tags.find((tag) => tag.length === 1 && tag[0] && ['sciencefiction', 'research'].includes(tag[0]))?.[0] as TagCategory | undefined
const result: { type?: TagType; category?: TagCategory } = {}
if (typeValue) {
result.type = typeValue
}
if (categoryValue) {
result.category = categoryValue
}
return result
}
export function extractCommonTags(findTag: (key: string) => string | undefined, hasTag: (key: string) => boolean) {
return {
id: findTag('id'),
paywall: hasTag('paywall'),
payment: hasTag('payment'),
title: findTag('title'),
preview: findTag('preview'),
description: findTag('description'),
mainnetAddress: findTag('mainnet_address'),
totalSponsoring: parseNumericTag(findTag, 'total_sponsoring'),
pictureUrl: findTag('picture'),
seriesId: findTag('series'),
coverUrl: findTag('cover'),
bannerUrl: findTag('banner'),
zapAmount: parseNumericTag(findTag, 'zap'),
invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'),
encryptedKey: findTag('encrypted_key'),
articleId: findTag('article'),
reviewerPubkey: findTag('reviewer'),
}
}
export function extractTagsFromEvent(event: { tags: string[][] }): {
type?: TagType | undefined
category?: TagCategory | undefined
id?: string | undefined
paywall: boolean
payment: boolean
title?: string | undefined
preview?: string | undefined
description?: string | undefined
mainnetAddress?: string | undefined
totalSponsoring?: number | undefined
seriesId?: string | undefined
coverUrl?: string | undefined
bannerUrl?: string | undefined
zapAmount?: number | undefined
invoice?: string | undefined
paymentHash?: string | undefined
articleId?: string | undefined
reviewerPubkey?: string | undefined
[key: string]: unknown
} {
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
const hasTag = (key: string) => event.tags.some((tag) => tag[0] === key || (tag.length === 1 && tag[0] === key))
const typeCategory = extractTypeAndCategory(event)
const commonTags = extractCommonTags(findTag, hasTag)
return {
...(typeCategory.type ? { type: typeCategory.type } : {}),
...(typeCategory.category ? { category: typeCategory.category } : {}),
...commonTags,
}
}

View File

@ -0,0 +1,46 @@
import type { TagType, TagCategory } from './nostrTagSystemTypes'
export function addSimpleTagFilter(filter: Record<string, string[] | number[]>, tagName: string, condition: boolean): void {
if (condition) {
filter[`#${tagName}`] = ['']
}
}
export function addValueTagFilter(filter: Record<string, string[] | number[]>, tagName: string, value: string | undefined): void {
if (value) {
filter[`#${tagName}`] = [value]
}
}
export function buildTagFilter(params: {
type?: TagType
category?: TagCategory
id?: string
paywall?: boolean
payment?: boolean
seriesId?: string
articleId?: string
authorPubkey?: string
}): Record<string, string[] | number[]> {
const filter: Record<string, string[] | number[]> = {
kinds: [1], // All are kind 1 notes
}
if (params.type) {
filter[`#${params.type}`] = ['']
}
if (params.category) {
filter[`#${params.category}`] = ['']
}
addValueTagFilter(filter, 'id', params.id)
addSimpleTagFilter(filter, 'paywall', params.paywall === true)
addSimpleTagFilter(filter, 'payment', params.payment === true)
addValueTagFilter(filter, 'series', params.seriesId)
addValueTagFilter(filter, 'article', params.articleId)
if (params.authorPubkey) {
filter.authors = [params.authorPubkey]
}
return filter
}

View File

@ -0,0 +1,58 @@
/**
* New tag system based on:
* - #paywall: for paid publications
* - #sciencefiction or #research: for category
* - #author, #series, #publication, #quote: for type
* - #id_<id>: for identifier
* - #payment (optional): for payment notes
*
* Everything is a Nostr note (kind 1)
* All tags are in English
*/
export type TagType = 'author' | 'series' | 'publication' | 'quote'
export type TagCategory = 'sciencefiction' | 'research'
export interface BaseTags {
type: TagType
category: TagCategory
id: string
paywall?: boolean
payment?: boolean
}
export interface AuthorTags extends BaseTags {
type: 'author'
title: string
preview?: string
mainnetAddress?: string
totalSponsoring?: number
pictureUrl?: string
}
export interface SeriesTags extends BaseTags {
type: 'series'
title: string
description: string
preview?: string
coverUrl?: string
}
export interface PublicationTags extends BaseTags {
type: 'publication'
title: string
preview?: string
seriesId?: string
bannerUrl?: string
zapAmount?: number
invoice?: string
paymentHash?: string
encryptedKey?: string
}
export interface QuoteTags extends BaseTags {
type: 'quote'
articleId: string
reviewerPubkey?: string
title?: string
}

View File

@ -1,7 +1,6 @@
import type { Event } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
import { getPrimaryRelaySync } from './config'
function createZapFilters(targetPubkey: string, targetEventId: string, userPubkey: string) {
return [
@ -30,6 +29,25 @@ async function isValidZapReceipt(
/**
* Check if user has paid for an article by looking for zap receipts
*/
function handleZapReceiptEvent(
event: Event,
targetEventId: string,
targetPubkey: string,
userPubkey: string,
amount: number,
finalize: (value: boolean) => void,
resolved: { current: boolean }
): void {
if (resolved.current) {
return
}
void isValidZapReceipt(event, targetEventId, targetPubkey, userPubkey, amount).then((isValid) => {
if (isValid) {
finalize(true)
}
})
}
export function checkZapReceipt(
pool: SimplePool,
targetPubkey: string,
@ -43,7 +61,8 @@ export function checkZapReceipt(
return new Promise((resolve) => {
let resolved = false
const sub = pool.sub([RELAY_URL], createZapFilters(targetPubkey, targetEventId, userPubkey))
const relayUrl = getPrimaryRelaySync()
const sub = pool.sub([relayUrl], createZapFilters(targetPubkey, targetEventId, userPubkey))
const finalize = (value: boolean) => {
if (resolved) {
@ -54,15 +73,9 @@ export function checkZapReceipt(
resolve(value)
}
const resolvedRef = { current: resolved }
sub.on('event', (event: Event) => {
if (resolved) {
return
}
void isValidZapReceipt(event, targetEventId, targetPubkey, userPubkey, amount).then((isValid) => {
if (isValid) {
finalize(true)
}
})
handleZapReceiptEvent(event, targetEventId, targetPubkey, userPubkey, amount, finalize, resolvedRef)
})
const end = () => finalize(false)

View File

@ -3,8 +3,7 @@ import { nostrService } from './nostr'
import { zapVerificationService } from './zapVerification'
import type { Notification } from '@/types/notifications'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
import { getPrimaryRelaySync } from './config'
function createZapReceiptFilters(userPubkey: string) {
return [
@ -86,7 +85,8 @@ export class NotificationService {
const filters = createZapReceiptFilters(userPubkey)
const poolWithSub = pool as SimplePoolWithSub
const sub = poolWithSub.sub([RELAY_URL], filters)
const relayUrl = getPrimaryRelaySync()
const sub = poolWithSub.sub([relayUrl], filters)
registerZapSubscription(sub, userPubkey, onNotification)

View File

@ -25,34 +25,42 @@ export class PaymentService {
* Create a Lightning invoice for an article payment
* First checks if author has created an invoice in the event tags, otherwise creates a new one
*/
private validateArticleAmount(zapAmount: number): { valid: boolean; error?: string } {
const expectedAmount = PLATFORM_COMMISSIONS.article.total
if (zapAmount !== expectedAmount) {
return {
valid: false,
error: `Invalid article payment amount: ${zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
}
}
return { valid: true }
}
private validateInvoiceAmount(invoice: AlbyInvoice): { valid: boolean; error?: string } {
const split = calculateArticleSplit()
if (invoice.amount !== split.total) {
return {
valid: false,
error: `Invoice amount mismatch: ${invoice.amount} sats. Expected ${split.total} sats (${split.author} to author, ${split.platform} commission)`,
}
}
return { valid: true }
}
async createArticlePayment(request: PaymentRequest): Promise<PaymentResult> {
try {
// Verify article amount matches expected commission structure
const expectedAmount = PLATFORM_COMMISSIONS.article.total
if (request.article.zapAmount !== expectedAmount) {
return {
success: false,
error: `Invalid article payment amount: ${request.article.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
}
const amountValidation = this.validateArticleAmount(request.article.zapAmount)
if (!amountValidation.valid) {
return { success: false, error: amountValidation.error ?? 'Invalid amount' }
}
const invoice = await resolveArticleInvoice(request.article)
// Verify invoice amount matches expected commission structure
const split = calculateArticleSplit()
if (invoice.amount !== split.total) {
return {
success: false,
error: `Invoice amount mismatch: ${invoice.amount} sats. Expected ${split.total} sats (${split.author} to author, ${split.platform} commission)`,
}
const invoiceValidation = this.validateInvoiceAmount(invoice)
if (!invoiceValidation.valid) {
return { success: false, error: invoiceValidation.error ?? 'Invalid invoice amount' }
}
// Create zap request event on Nostr
await nostrService.createZapRequest(
request.article.pubkey,
request.article.id,
request.article.zapAmount
)
await nostrService.createZapRequest(request.article.pubkey, request.article.id, request.article.zapAmount)
return {
success: true,

View File

@ -1,265 +1 @@
import { nostrService } from './nostr'
import { articlePublisher } from './articlePublisher'
import { getStoredPrivateContent } from './articleStorage'
import { platformTracking } from './platformTracking'
import { calculateArticleSplit } from './platformCommissions'
import { automaticTransferService } from './automaticTransfer'
import { lightningAddressService } from './lightningAddress'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
/**
* Poll for payment completion via zap receipt verification
* After payment is confirmed, sends private content to the user
*/
async function pollPaymentUntilDeadline(
articleId: string,
articlePubkey: string,
amount: number,
recipientPubkey: string,
interval: number,
deadline: number
): Promise<boolean> {
try {
const zapReceiptExists = await nostrService.checkZapReceipt(articlePubkey, articleId, amount, recipientPubkey)
if (zapReceiptExists) {
const zapReceiptId = await getZapReceiptId(articlePubkey, articleId, amount, recipientPubkey)
await sendPrivateContentAfterPayment(articleId, recipientPubkey, amount, zapReceiptId)
return true
}
} catch (error) {
console.error('Error checking zap receipt:', error)
}
if (Date.now() > deadline) {
return false
}
return new Promise<boolean>((resolve) => {
setTimeout(() => {
void pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
.then(resolve)
.catch(() => resolve(false))
}, interval)
})
}
export async function waitForArticlePayment(
_paymentHash: string,
articleId: string,
articlePubkey: string,
amount: number,
recipientPubkey: string,
timeout: number = 300000 // 5 minutes
): Promise<boolean> {
const interval = 2000
const deadline = Date.now() + timeout
try {
return await pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
} catch (error) {
console.error('Wait for payment error:', error)
return false
}
}
async function getZapReceiptId(
articlePubkey: string,
articleId: string,
amount: number,
recipientPubkey: string
): Promise<string | undefined> {
try {
const pool = nostrService.getPool()
if (!pool) {
return undefined
}
const filters = [
{
kinds: [9735],
'#p': [articlePubkey],
'#e': [articleId],
limit: 1,
},
]
return new Promise((resolve) => {
let resolved = false
const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
const sub = poolWithSub.sub([RELAY_URL], filters)
const finalize = (value: string | undefined) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event) => {
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
const amountInSats = amountTag ? Math.floor(parseInt(amountTag, 10) / 1000) : 0
if (amountInSats === amount && event.pubkey === recipientPubkey) {
finalize(event.id)
}
})
sub.on('eose', () => finalize(undefined))
setTimeout(() => finalize(undefined), 3000)
})
} catch (error) {
console.error('Error getting zap receipt ID', {
articleId,
recipientPubkey,
error: error instanceof Error ? error.message : 'Unknown error',
})
return undefined
}
}
/**
* Send private content to user after payment confirmation
* Returns true if content was successfully sent and verified
*/
async function sendPrivateContentAfterPayment(
articleId: string,
recipientPubkey: string,
amount: number,
zapReceiptId?: string
): Promise<boolean> {
const storedContent = await getStoredPrivateContent(articleId)
if (!storedContent) {
console.error('Stored private content not found for article', {
articleId,
recipientPubkey,
timestamp: new Date().toISOString(),
})
return false
}
const authorPrivateKey = nostrService.getPrivateKey()
if (!authorPrivateKey) {
console.error('Author private key not available, cannot send private content automatically', {
articleId,
recipientPubkey,
authorPubkey: storedContent.authorPubkey,
timestamp: new Date().toISOString(),
})
return false
}
const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, authorPrivateKey)
if (result.success && result.messageEventId) {
const timestamp = Math.floor(Date.now() / 1000)
// Verify payment amount matches expected commission structure
const expectedSplit = calculateArticleSplit()
if (amount !== expectedSplit.total) {
console.error('Payment amount does not match expected commission structure', {
articleId,
paidAmount: amount,
expectedTotal: expectedSplit.total,
expectedAuthor: expectedSplit.author,
expectedPlatform: expectedSplit.platform,
timestamp: new Date().toISOString(),
})
}
// Track content delivery with commission information
const trackingData: import('./platformTracking').ContentDeliveryTracking = {
articleId,
articlePubkey: storedContent.authorPubkey,
recipientPubkey,
messageEventId: result.messageEventId,
amount,
authorAmount: expectedSplit.author,
platformCommission: expectedSplit.platform,
timestamp,
verified: result.verified ?? false,
}
if (zapReceiptId) {
trackingData.zapReceiptId = zapReceiptId
}
await platformTracking.trackContentDelivery(trackingData, authorPrivateKey)
// Log commission information for platform tracking
console.log('Article payment processed with commission', {
articleId,
totalAmount: amount,
authorPortion: expectedSplit.author,
platformCommission: expectedSplit.platform,
recipientPubkey,
timestamp: new Date().toISOString(),
})
// Trigger automatic transfer of author portion
try {
// Get author's Lightning address from profile
const authorLightningAddress = await lightningAddressService.getLightningAddress(storedContent.authorPubkey)
if (authorLightningAddress) {
const transferResult = await automaticTransferService.transferAuthorPortion(
authorLightningAddress,
articleId,
storedContent.authorPubkey,
amount
)
if (!transferResult.success) {
console.warn('Automatic transfer failed, will be retried later', {
articleId,
authorPubkey: storedContent.authorPubkey,
error: transferResult.error,
timestamp: new Date().toISOString(),
})
}
} else {
console.warn('Author Lightning address not available for automatic transfer', {
articleId,
authorPubkey: storedContent.authorPubkey,
timestamp: new Date().toISOString(),
})
// Transfer will need to be done manually later
}
} catch (error) {
console.error('Error triggering automatic transfer', {
articleId,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
// Don't fail the payment process if transfer fails
}
if (result.verified) {
console.log('Private content sent and verified on relay', {
articleId,
recipientPubkey,
messageEventId: result.messageEventId,
timestamp: new Date().toISOString(),
})
return true
} else {
console.warn('Private content sent but not yet verified on relay', {
articleId,
recipientPubkey,
messageEventId: result.messageEventId,
timestamp: new Date().toISOString(),
})
return true
}
} else {
console.error('Failed to send private content, but payment was confirmed', {
articleId,
recipientPubkey,
error: result.error,
timestamp: new Date().toISOString(),
})
return false
}
}
export { waitForArticlePayment } from './paymentPollingCore'

57
lib/paymentPollingCore.ts Normal file
View File

@ -0,0 +1,57 @@
import { nostrService } from './nostr'
import { getZapReceiptId } from './paymentPollingZapReceipt'
import { sendPrivateContentAfterPayment } from './paymentPollingMain'
/**
* Poll for payment completion via zap receipt verification
* After payment is confirmed, sends private content to the user
*/
async function pollPaymentUntilDeadline(
articleId: string,
articlePubkey: string,
amount: number,
recipientPubkey: string,
interval: number,
deadline: number
): Promise<boolean> {
try {
const zapReceiptExists = await nostrService.checkZapReceipt(articlePubkey, articleId, amount, recipientPubkey)
if (zapReceiptExists) {
const zapReceiptId = await getZapReceiptId(articlePubkey, articleId, amount, recipientPubkey)
await sendPrivateContentAfterPayment(articleId, recipientPubkey, amount, zapReceiptId)
return true
}
} catch (error) {
console.error('Error checking zap receipt:', error)
}
if (Date.now() > deadline) {
return false
}
return new Promise<boolean>((resolve) => {
setTimeout(() => {
void pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
.then(resolve)
.catch(() => resolve(false))
}, interval)
})
}
export async function waitForArticlePayment(
_paymentHash: string,
articleId: string,
articlePubkey: string,
amount: number,
recipientPubkey: string,
timeout: number = 300000 // 5 minutes
): Promise<boolean> {
const interval = 2000
const deadline = Date.now() + timeout
try {
return await pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
} catch (error) {
console.error('Wait for payment error:', error)
return false
}
}

48
lib/paymentPollingMain.ts Normal file
View File

@ -0,0 +1,48 @@
import { getStoredPrivateContent } from './articleStorage'
import { nostrService } from './nostr'
import { articlePublisher } from './articlePublisher'
import { validatePaymentData, verifyPaymentAmount } from './paymentPollingValidation'
import { createTrackingData, triggerAutomaticTransfer, logPaymentResult } from './paymentPollingTracking'
import { platformTracking } from './platformTracking'
/**
* Send private content to user after payment confirmation
* Returns true if content was successfully sent and verified
*/
export async function sendPrivateContentAfterPayment(
articleId: string,
recipientPubkey: string,
amount: number,
zapReceiptId?: string
): Promise<boolean> {
const storedContent = await getStoredPrivateContent(articleId)
const authorPrivateKey = nostrService.getPrivateKey()
const validation = validatePaymentData(storedContent, authorPrivateKey, articleId, recipientPubkey)
if (!validation.valid || !validation.storedContent || !validation.authorPrivateKey) {
return false
}
const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, validation.authorPrivateKey)
if (result.success && result.messageEventId) {
verifyPaymentAmount(amount, articleId)
const trackingData = createTrackingData(
articleId,
validation.storedContent.authorPubkey,
recipientPubkey,
result.messageEventId,
amount,
result.verified ?? false,
zapReceiptId
)
await platformTracking.trackContentDelivery(trackingData, validation.authorPrivateKey)
await triggerAutomaticTransfer(validation.storedContent.authorPubkey, articleId, amount)
return logPaymentResult(result, articleId, recipientPubkey, amount)
}
return logPaymentResult(result, articleId, recipientPubkey, amount)
}

View File

@ -0,0 +1,128 @@
import { calculateArticleSplit } from './platformCommissions'
import { lightningAddressService } from './lightningAddress'
import { automaticTransferService } from './automaticTransfer'
export function createTrackingData(
articleId: string,
authorPubkey: string,
recipientPubkey: string,
messageEventId: string,
amount: number,
verified: boolean,
zapReceiptId?: string
): import('./platformTracking').ContentDeliveryTracking {
const expectedSplit = calculateArticleSplit()
const timestamp = Math.floor(Date.now() / 1000)
const trackingData: import('./platformTracking').ContentDeliveryTracking = {
articleId,
articlePubkey: authorPubkey,
recipientPubkey,
messageEventId,
amount,
authorAmount: expectedSplit.author,
platformCommission: expectedSplit.platform,
timestamp,
verified,
}
if (zapReceiptId) {
trackingData.zapReceiptId = zapReceiptId
}
return trackingData
}
export async function triggerAutomaticTransfer(
authorPubkey: string,
articleId: string,
amount: number
): Promise<void> {
try {
const authorLightningAddress = await lightningAddressService.getLightningAddress(authorPubkey)
if (authorLightningAddress) {
const transferResult = await automaticTransferService.transferAuthorPortion(
authorLightningAddress,
articleId,
authorPubkey,
amount
)
if (!transferResult.success) {
console.warn('Automatic transfer failed, will be retried later', {
articleId,
authorPubkey,
error: transferResult.error,
timestamp: new Date().toISOString(),
})
}
} else {
console.warn('Author Lightning address not available for automatic transfer', {
articleId,
authorPubkey,
timestamp: new Date().toISOString(),
})
}
} catch (error) {
console.error('Error triggering automatic transfer', {
articleId,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
}
}
export function logPaymentSuccess(
articleId: string,
recipientPubkey: string,
amount: number,
messageEventId: string,
verified: boolean
): void {
const expectedSplit = calculateArticleSplit()
console.log('Article payment processed with commission', {
articleId,
totalAmount: amount,
authorPortion: expectedSplit.author,
platformCommission: expectedSplit.platform,
recipientPubkey,
timestamp: new Date().toISOString(),
})
if (verified) {
console.log('Private content sent and verified on relay', {
articleId,
recipientPubkey,
messageEventId,
timestamp: new Date().toISOString(),
})
} else {
console.warn('Private content sent but not yet verified on relay', {
articleId,
recipientPubkey,
messageEventId,
timestamp: new Date().toISOString(),
})
}
}
export function logPaymentResult(
result: { success: boolean; messageEventId?: string; verified?: boolean; error?: string },
articleId: string,
recipientPubkey: string,
amount: number
): boolean {
if (result.success && result.messageEventId) {
logPaymentSuccess(articleId, recipientPubkey, amount, result.messageEventId, result.verified ?? false)
return true
} else {
console.error('Failed to send private content, but payment was confirmed', {
articleId,
recipientPubkey,
error: result.error,
timestamp: new Date().toISOString(),
})
return false
}
}

View File

@ -0,0 +1,43 @@
import { calculateArticleSplit } from './platformCommissions'
export function validatePaymentData(
storedContent: { authorPubkey: string } | null,
authorPrivateKey: string | null,
articleId: string,
recipientPubkey: string
): { valid: boolean; storedContent?: { authorPubkey: string }; authorPrivateKey?: string } {
if (!storedContent) {
console.error('Stored private content not found for article', {
articleId,
recipientPubkey,
timestamp: new Date().toISOString(),
})
return { valid: false }
}
if (!authorPrivateKey) {
console.error('Author private key not available, cannot send private content automatically', {
articleId,
recipientPubkey,
authorPubkey: storedContent.authorPubkey,
timestamp: new Date().toISOString(),
})
return { valid: false }
}
return { valid: true, storedContent, authorPrivateKey }
}
export function verifyPaymentAmount(amount: number, articleId: string): void {
const expectedSplit = calculateArticleSplit()
if (amount !== expectedSplit.total) {
console.error('Payment amount does not match expected commission structure', {
articleId,
paidAmount: amount,
expectedTotal: expectedSplit.total,
expectedAuthor: expectedSplit.author,
expectedPlatform: expectedSplit.platform,
timestamp: new Date().toISOString(),
})
}
}

View File

@ -0,0 +1,83 @@
import { nostrService } from './nostr'
import { getPrimaryRelaySync } from './config'
export function parseZapAmount(event: import('nostr-tools').Event): number {
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
return amountTag ? Math.floor(parseInt(amountTag, 10) / 1000) : 0
}
export function createZapReceiptSubscription(poolWithSub: import('@/types/nostr-tools-extended').SimplePoolWithSub, articlePubkey: string, articleId: string) {
const filters = [
{
kinds: [9735],
'#p': [articlePubkey],
'#e': [articleId],
limit: 1,
},
]
const relayUrl = getPrimaryRelaySync()
return poolWithSub.sub([relayUrl], filters)
}
export function handleZapReceiptEvent(
event: import('nostr-tools').Event,
amount: number,
recipientPubkey: string,
finalize: (value: string | undefined) => void
): void {
const amountInSats = parseZapAmount(event)
if (amountInSats === amount && event.pubkey === recipientPubkey) {
finalize(event.id)
}
}
export function createZapReceiptPromise(
sub: import('@/types/nostr-tools-extended').SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
amount: number,
recipientPubkey: string
): Promise<string | undefined> {
return new Promise((resolve) => {
let resolved = false
const finalize = (value: string | undefined) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event) => {
handleZapReceiptEvent(event, amount, recipientPubkey, finalize)
})
sub.on('eose', () => finalize(undefined))
setTimeout(() => finalize(undefined), 3000)
})
}
export async function getZapReceiptId(
articlePubkey: string,
articleId: string,
amount: number,
recipientPubkey: string
): Promise<string | undefined> {
try {
const pool = nostrService.getPool()
if (!pool) {
return undefined
}
const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
const sub = createZapReceiptSubscription(poolWithSub, articlePubkey, articleId)
return createZapReceiptPromise(sub, amount, recipientPubkey)
} catch (error) {
console.error('Error getting zap receipt ID', {
articleId,
recipientPubkey,
error: error instanceof Error ? error.message : 'Unknown error',
})
return undefined
}
}

View File

@ -49,8 +49,10 @@ export const PLATFORM_COMMISSIONS = {
/**
* Platform Lightning address/node for receiving commissions
* This should be configured with the platform's Lightning node
*
* @deprecated Use getPlatformLightningAddress() or getPlatformLightningAddressSync() from './platformConfig' instead
*/
export const PLATFORM_LIGHTNING_ADDRESS = process.env.NEXT_PUBLIC_PLATFORM_LIGHTNING_ADDRESS || ''
export const PLATFORM_LIGHTNING_ADDRESS = ''
/**
* Calculate commission split for article payment

View File

@ -1,9 +1,29 @@
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 = process.env.NEXT_PUBLIC_PLATFORM_LIGHTNING_ADDRESS || ''
export const PLATFORM_LIGHTNING_ADDRESS = ''
/**
* Get platform Lightning address (async)
* Uses IndexedDB if available, otherwise returns default
*/
export async function getPlatformLightningAddress(): Promise<string> {
return getAddress()
}
/**
* Get platform Lightning address (sync)
* Returns default if IndexedDB is not ready
*/
export function getPlatformLightningAddressSync(): string {
return getAddressSync()
}

View File

@ -1,22 +1,12 @@
import { Event, EventTemplate, getEventHash, signEvent } from 'nostr-tools'
import { Event } from 'nostr-tools'
import { nostrService } from './nostr'
import { PLATFORM_NPUB } from './platformConfig'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { ContentDeliveryTracking } from './platformTrackingTypes'
import { buildTrackingEvent } from './platformTrackingEvents'
import { parseTrackingEvent, createArticleDeliveriesSubscription, createRecipientDeliveriesSubscription } from './platformTrackingQueries'
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
export interface ContentDeliveryTracking {
articleId: string
articlePubkey: string
recipientPubkey: string
messageEventId: string
zapReceiptId?: string
amount: number
authorAmount?: number
platformCommission?: number
timestamp: number
verified: boolean
}
export type { ContentDeliveryTracking } from './platformTrackingTypes'
/**
* Platform tracking service
@ -25,7 +15,34 @@ export interface ContentDeliveryTracking {
*/
export class PlatformTrackingService {
private readonly platformPubkey: string = PLATFORM_NPUB
private readonly trackingKind = 30078 // Custom kind for platform tracking
private async publishTrackingEvent(event: Event): Promise<void> {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
const { getPrimaryRelaySync } = await import('./config')
const relayUrl = getPrimaryRelaySync()
const pubs = poolWithSub.publish([relayUrl], event)
await Promise.all(pubs)
}
private validateTrackingPool(): { pool: SimplePoolWithSub; authorPubkey: string } | null {
const pool = nostrService.getPool()
if (!pool) {
console.error('Pool not initialized for platform tracking')
return null
}
const authorPubkey = nostrService.getPublicKey()
if (!authorPubkey) {
console.error('Author public key not available for tracking')
return null
}
return { pool: pool as SimplePoolWithSub, authorPubkey }
}
/**
* Publish a content delivery tracking event
@ -37,62 +54,14 @@ export class PlatformTrackingService {
authorPrivateKey: string
): Promise<string | null> {
try {
const pool = nostrService.getPool()
if (!pool) {
console.error('Pool not initialized for platform tracking')
const validation = this.validateTrackingPool()
if (!validation) {
return null
}
const authorPubkey = nostrService.getPublicKey()
if (!authorPubkey) {
console.error('Author public key not available for tracking')
return null
}
const eventTemplate: EventTemplate = {
kind: this.trackingKind,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', this.platformPubkey], // Tag platform for querying
['article', tracking.articleId],
['author', tracking.articlePubkey],
['recipient', tracking.recipientPubkey],
['message', tracking.messageEventId],
['amount', tracking.amount.toString()],
...(tracking.authorAmount ? [['author_amount', tracking.authorAmount.toString()]] : []),
...(tracking.platformCommission ? [['platform_commission', tracking.platformCommission.toString()]] : []),
['verified', tracking.verified ? 'true' : 'false'],
['timestamp', tracking.timestamp.toString()],
...(tracking.zapReceiptId ? [['zap_receipt', tracking.zapReceiptId]] : []),
],
content: JSON.stringify({
articleId: tracking.articleId,
articlePubkey: tracking.articlePubkey,
recipientPubkey: tracking.recipientPubkey,
messageEventId: tracking.messageEventId,
amount: tracking.amount,
authorAmount: tracking.authorAmount,
platformCommission: tracking.platformCommission,
verified: tracking.verified,
timestamp: tracking.timestamp,
zapReceiptId: tracking.zapReceiptId,
}),
}
const unsignedEvent = {
pubkey: authorPubkey,
...eventTemplate,
}
const event: Event = {
...unsignedEvent,
id: getEventHash(unsignedEvent),
sig: signEvent(unsignedEvent, authorPrivateKey),
} as Event
const poolWithSub = pool as SimplePoolWithSub
const pubs = poolWithSub.publish([RELAY_URL], event)
await Promise.all(pubs)
const { authorPubkey } = validation
const event = buildTrackingEvent(tracking, authorPubkey, authorPrivateKey, this.platformPubkey)
await this.publishTrackingEvent(event)
console.log('Platform tracking event published', {
eventId: event.id,
@ -127,20 +96,11 @@ export class PlatformTrackingService {
return []
}
const filters = [
{
kinds: [this.trackingKind],
'#p': [this.platformPubkey],
'#article': [articleId],
limit: 100,
},
]
return new Promise((resolve) => {
const deliveries: ContentDeliveryTracking[] = []
let resolved = false
const poolWithSub = pool as SimplePoolWithSub
const sub = poolWithSub.sub([RELAY_URL], filters)
const sub = createArticleDeliveriesSubscription(poolWithSub, articleId, this.platformPubkey)
const finalize = () => {
if (resolved) {
@ -152,34 +112,9 @@ export class PlatformTrackingService {
}
sub.on('event', (event: Event) => {
try {
const data = JSON.parse(event.content) as ContentDeliveryTracking
const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
const authorAmountTag = event.tags.find((tag) => tag[0] === 'author_amount')?.[1]
const platformCommissionTag = event.tags.find((tag) => tag[0] === 'platform_commission')?.[1]
const delivery: ContentDeliveryTracking = {
...data,
}
if (authorAmountTag) {
delivery.authorAmount = parseInt(authorAmountTag, 10)
}
if (platformCommissionTag) {
delivery.platformCommission = parseInt(platformCommissionTag, 10)
}
if (zapReceiptTag) {
delivery.zapReceiptId = zapReceiptTag
}
const delivery = parseTrackingEvent(event)
if (delivery) {
deliveries.push(delivery)
} catch (error) {
console.error('Error parsing tracking event', {
eventId: event.id,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
})
@ -205,20 +140,11 @@ export class PlatformTrackingService {
return []
}
const filters = [
{
kinds: [this.trackingKind],
'#p': [this.platformPubkey],
'#recipient': [recipientPubkey],
limit: 100,
},
]
return new Promise((resolve) => {
const deliveries: ContentDeliveryTracking[] = []
let resolved = false
const poolWithSub = pool as SimplePoolWithSub
const sub = poolWithSub.sub([RELAY_URL], filters)
const sub = createRecipientDeliveriesSubscription(poolWithSub, recipientPubkey, this.platformPubkey)
const finalize = () => {
if (resolved) {
@ -230,24 +156,9 @@ export class PlatformTrackingService {
}
sub.on('event', (event: Event) => {
try {
const data = JSON.parse(event.content) as ContentDeliveryTracking
const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
const delivery: ContentDeliveryTracking = {
...data,
}
if (zapReceiptTag) {
delivery.zapReceiptId = zapReceiptTag
}
const delivery = parseTrackingEvent(event)
if (delivery) {
deliveries.push(delivery)
} catch (error) {
console.error('Error parsing tracking event', {
eventId: event.id,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
})

View File

@ -0,0 +1,60 @@
import { Event, EventTemplate, getEventHash, signEvent } from 'nostr-tools'
import type { ContentDeliveryTracking } from './platformTrackingTypes'
const TRACKING_KIND = 30078 // Custom kind for platform tracking
export function buildTrackingTags(tracking: ContentDeliveryTracking, platformPubkey: string): string[][] {
return [
['p', platformPubkey],
['article', tracking.articleId],
['author', tracking.articlePubkey],
['recipient', tracking.recipientPubkey],
['message', tracking.messageEventId],
['amount', tracking.amount.toString()],
...(tracking.authorAmount ? [['author_amount', tracking.authorAmount.toString()]] : []),
...(tracking.platformCommission ? [['platform_commission', tracking.platformCommission.toString()]] : []),
['verified', tracking.verified ? 'true' : 'false'],
['timestamp', tracking.timestamp.toString()],
...(tracking.zapReceiptId ? [['zap_receipt', tracking.zapReceiptId]] : []),
]
}
export function buildTrackingEvent(
tracking: ContentDeliveryTracking,
authorPubkey: string,
authorPrivateKey: string,
platformPubkey: string
): Event {
const eventTemplate: EventTemplate = {
kind: TRACKING_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: buildTrackingTags(tracking, platformPubkey),
content: JSON.stringify({
articleId: tracking.articleId,
articlePubkey: tracking.articlePubkey,
recipientPubkey: tracking.recipientPubkey,
messageEventId: tracking.messageEventId,
amount: tracking.amount,
authorAmount: tracking.authorAmount,
platformCommission: tracking.platformCommission,
verified: tracking.verified,
timestamp: tracking.timestamp,
zapReceiptId: tracking.zapReceiptId,
}),
}
const unsignedEvent = {
pubkey: authorPubkey,
...eventTemplate,
}
return {
...unsignedEvent,
id: getEventHash(unsignedEvent),
sig: signEvent(unsignedEvent, authorPrivateKey),
} as Event
}
export function getTrackingKind(): number {
return TRACKING_KIND
}

View File

@ -0,0 +1,64 @@
import { Event } from 'nostr-tools'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { getPrimaryRelaySync } from './config'
import type { ContentDeliveryTracking } from './platformTrackingTypes'
import { getTrackingKind } from './platformTrackingEvents'
export function parseTrackingEvent(event: Event): ContentDeliveryTracking | null {
try {
const data = JSON.parse(event.content) as ContentDeliveryTracking
const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
const authorAmountTag = event.tags.find((tag) => tag[0] === 'author_amount')?.[1]
const platformCommissionTag = event.tags.find((tag) => tag[0] === 'platform_commission')?.[1]
const delivery: ContentDeliveryTracking = {
...data,
}
if (authorAmountTag) {
delivery.authorAmount = parseInt(authorAmountTag, 10)
}
if (platformCommissionTag) {
delivery.platformCommission = parseInt(platformCommissionTag, 10)
}
if (zapReceiptTag) {
delivery.zapReceiptId = zapReceiptTag
}
return delivery
} catch (error) {
console.error('Error parsing tracking event', {
eventId: event.id,
error: error instanceof Error ? error.message : 'Unknown error',
})
return null
}
}
export function createArticleDeliveriesSubscription(pool: SimplePoolWithSub, articleId: string, platformPubkey: string) {
const filters = [
{
kinds: [getTrackingKind()],
'#p': [platformPubkey],
'#article': [articleId],
limit: 100,
},
]
const relayUrl = getPrimaryRelaySync()
return pool.sub([relayUrl], filters)
}
export function createRecipientDeliveriesSubscription(pool: SimplePoolWithSub, recipientPubkey: string, platformPubkey: string) {
const filters = [
{
kinds: [getTrackingKind()],
'#p': [platformPubkey],
'#recipient': [recipientPubkey],
limit: 100,
},
]
const relayUrl = getPrimaryRelaySync()
return pool.sub([relayUrl], filters)
}

View File

@ -0,0 +1,12 @@
export interface ContentDeliveryTracking {
articleId: string
articlePubkey: string
recipientPubkey: string
messageEventId: string
zapReceiptId?: string
amount: number
authorAmount?: number
platformCommission?: number
timestamp: number
verified: boolean
}

View File

@ -1,10 +1,9 @@
import { getAlbyService } from './alby'
import { calculateReviewSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
import { automaticTransferService } from './automaticTransfer'
import { nostrService } from './nostr'
import { lightningAddressService } from './lightningAddress'
import type { AlbyInvoice } from '@/types/alby'
import type { Event } from 'nostr-tools'
import { calculateReviewSplit } from './platformCommissions'
import type { ReviewRewardRequest, ReviewRewardResult } from './reviewRewardTypes'
import { createReviewInvoice } from './reviewRewardInvoice'
import { transferReviewerPortionIfAvailable } from './reviewRewardTransfer'
import { trackReviewReward } from './reviewRewardTracking'
import { updateReviewWithReward } from './reviewRewardUpdate'
/**
* Review reward service
@ -14,46 +13,13 @@ import type { Event } from 'nostr-tools'
* - Reviewer: 49 sats
* - Platform: 21 sats
*/
export interface ReviewRewardRequest {
reviewId: string
articleId: string
reviewerPubkey: string
reviewerLightningAddress?: string
authorPubkey: string
authorPrivateKey: string
}
export interface ReviewRewardResult {
success: boolean
invoice?: AlbyInvoice
paymentHash?: string
error?: string
split: {
reviewer: number
platform: number
total: number
}
}
export type { ReviewRewardRequest, ReviewRewardResult } from './reviewRewardTypes'
export class ReviewRewardService {
/**
* Create review reward payment with commission split
*/
async createReviewRewardPayment(request: ReviewRewardRequest): Promise<ReviewRewardResult> {
try {
const split = calculateReviewSplit()
// Verify author has permission to reward this review
// (should be verified before calling this function)
const alby = getAlbyService()
await alby.enable()
const invoice = await alby.createInvoice({
amount: split.total,
description: `Review reward: ${request.reviewId} (${split.reviewer} sats to reviewer, ${split.platform} sats commission)`,
expiry: 3600, // 1 hour
})
const invoice = await createReviewInvoice(split, request)
console.log('Review reward invoice created', {
reviewId: request.reviewId,
@ -97,44 +63,9 @@ export class ReviewRewardService {
): Promise<boolean> {
try {
const split = calculateReviewSplit()
// Get reviewer Lightning address if not provided
let reviewerLightningAddress: string | undefined = request.reviewerLightningAddress
if (!reviewerLightningAddress) {
const address = await lightningAddressService.getLightningAddress(request.reviewerPubkey)
reviewerLightningAddress = address ?? undefined
}
// Transfer reviewer portion
if (reviewerLightningAddress) {
const transferResult = await automaticTransferService.transferReviewerPortion(
reviewerLightningAddress,
request.reviewId,
request.reviewerPubkey,
split.total
)
if (!transferResult.success) {
console.error('Failed to transfer reviewer portion', {
reviewId: request.reviewId,
error: transferResult.error,
timestamp: new Date().toISOString(),
})
// Continue anyway - transfer can be done manually later
}
} else {
console.warn('Reviewer Lightning address not available for automatic transfer', {
reviewId: request.reviewId,
reviewerPubkey: request.reviewerPubkey,
timestamp: new Date().toISOString(),
})
}
// Track the reward payment
await this.trackReviewReward(request, split, paymentHash)
// Update review event with reward tag
await this.updateReviewWithReward(request.reviewId, request.authorPrivateKey)
await transferReviewerPortionIfAvailable(request, split)
await trackReviewReward(request, split, paymentHash)
await updateReviewWithReward(request.reviewId, request.authorPrivateKey)
console.log('Review reward processed', {
reviewId: request.reviewId,
@ -156,134 +87,6 @@ export class ReviewRewardService {
}
}
/**
* Track review reward payment
*/
private async trackReviewReward(
request: ReviewRewardRequest,
split: { reviewer: number; platform: number; total: number },
paymentHash: string
): Promise<void> {
try {
// In production, publish tracking event on Nostr similar to article payments
console.log('Review reward tracked', {
reviewId: request.reviewId,
articleId: request.articleId,
reviewerPubkey: request.reviewerPubkey,
authorPubkey: request.authorPubkey,
reviewerAmount: split.reviewer,
platformCommission: split.platform,
paymentHash,
timestamp: new Date().toISOString(),
})
} catch (error) {
console.error('Error tracking review reward', {
reviewId: request.reviewId,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
}
}
/**
* Update review event with reward tag
* Publishes a new event that references the original review with reward tags
*/
private async updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise<void> {
try {
const pool = nostrService.getPool()
if (!pool) {
throw new Error('Pool not initialized')
}
// Get the original event from pool
const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
const filters = [
{
kinds: [1],
ids: [reviewId],
limit: 1,
},
]
const originalEvent = await new Promise<Event | null>((resolve) => {
let resolved = false
const sub = poolWithSub.sub([RELAY_URL], filters)
const finalize = (value: Event | null) => {
if (resolved) {
return
}
resolved = true
sub.unsub()
resolve(value)
}
sub.on('event', (event: Event) => {
finalize(event)
})
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)
})
if (!originalEvent) {
console.error('Original review event not found', {
reviewId,
timestamp: new Date().toISOString(),
})
return
}
// Check if already rewarded
const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
if (alreadyRewarded) {
console.log('Review already marked as rewarded', {
reviewId,
timestamp: new Date().toISOString(),
})
return
}
// Create updated event with reward tags
nostrService.setPrivateKey(authorPrivateKey)
nostrService.setPublicKey(originalEvent.pubkey)
const updatedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [
...originalEvent.tags.filter((tag) => tag[0] !== 'rewarded' && tag[0] !== 'reward_amount'),
['e', reviewId], // Reference to original review
['rewarded', 'true'],
['reward_amount', PLATFORM_COMMISSIONS.review.total.toString()],
],
content: originalEvent.content, // Keep original content
}
const publishedEvent = await nostrService.publishEvent(updatedEvent)
if (publishedEvent) {
console.log('Review updated with reward tag', {
reviewId,
updatedEventId: publishedEvent.id,
timestamp: new Date().toISOString(),
})
} else {
console.error('Failed to publish updated review event', {
reviewId,
timestamp: new Date().toISOString(),
})
}
} catch (error) {
console.error('Error updating review with reward', {
reviewId,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
}
}
}
export const reviewRewardService = new ReviewRewardService()

Some files were not shown because too many files have changed in this diff Show More