Remove connection button and replace with direct account creation/import options
This commit is contained in:
parent
aa21dc3ad6
commit
46d5f03fbe
@ -264,3 +264,119 @@ Ces consignes constituent un cadre de production strict. Elles imposent une anal
|
|||||||
* ARIA
|
* ARIA
|
||||||
* clavier
|
* clavier
|
||||||
* contraste
|
* 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
|
||||||
52
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
52
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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.
|
||||||
8
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
8
.gitea/ISSUE_TEMPLATE/config.yml
Normal 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
|
||||||
46
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
46
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal 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.
|
||||||
25
.gitea/ISSUE_TEMPLATE/question.md
Normal file
25
.gitea/ISSUE_TEMPLATE/question.md
Normal 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.
|
||||||
56
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
56
.gitea/PULL_REQUEST_TEMPLATE.md
Normal 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
119
CODE_OF_CONDUCT.md
Normal 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
|
||||||
257
CONTRIBUTING.md
257
CONTRIBUTING.md
@ -1,31 +1,240 @@
|
|||||||
# Contributing to zapwall4science
|
# Contributing to zapwall4Science
|
||||||
|
|
||||||
## Principles
|
Thank you for your interest in contributing to zapwall4Science! This document provides guidelines and instructions for contributing to the project.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Setup
|
## Table of Contents
|
||||||
- Node 18+, npm
|
|
||||||
- `npm install`
|
|
||||||
- `npm run lint`
|
|
||||||
- `npm run type-check`
|
|
||||||
|
|
||||||
## Coding guidelines
|
- [Code of Conduct](#code-of-conduct)
|
||||||
- Split large components/functions to stay within lint limits (max-lines, max-lines-per-function).
|
- [Getting Started](#getting-started)
|
||||||
- Prefer typed helpers/hooks; avoid duplication.
|
- [Development Setup](#development-setup)
|
||||||
- Errors must surface with clear messages; do not swallow exceptions.
|
- [Coding Guidelines](#coding-guidelines)
|
||||||
- Storage: IndexedDB encrypted (AES-GCM) via `lib/storage/cryptoHelpers.ts`; use provided helpers.
|
- [Workflow](#workflow)
|
||||||
- Nostr: use `lib/articleMutations.ts` and `lib/nostr*.ts` helpers; no direct fallbacks.
|
- [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
|
## Workflow
|
||||||
- Branch from main; keep commits focused.
|
|
||||||
- Run lint + type-check before PR.
|
|
||||||
- Document fixes in `fixKnowledge/` and features in `features/`.
|
|
||||||
|
|
||||||
## Accessibility
|
### Creating a Branch
|
||||||
- Respect ARIA, keyboard, contrast requirements; no regressions.
|
|
||||||
|
|
||||||
## What not to do
|
1. **Update your fork**:
|
||||||
- No analytics, no ad-hoc tests, no environment overrides, no silent retry/fallback.
|
```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
21
LICENSE
Normal 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
116
README.md
@ -1,7 +1,24 @@
|
|||||||
# zapwall4Science
|
# zapwall4Science
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](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.
|
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
|
## Features
|
||||||
|
|
||||||
- **Nostr Authentication**: Authenticate using Alby browser extension (NIP-07)
|
- **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
|
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)
|
The application stores all configuration in IndexedDB (browser storage) with hardcoded defaults. No environment variables are required.
|
||||||
- `NEXT_PUBLIC_NIP95_UPLOAD_URL`: NIP-95 media upload endpoint URL (required for image/video uploads)
|
|
||||||
|
|
||||||
### 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 Relay**: `wss://relay.damus.io` (default)
|
||||||
- [nostr.build](https://nostr.build/) - Public NIP-95 service
|
- **NIP-95 Upload API**: `https://nostr.build/api/v2/upload` (default)
|
||||||
- [void.cat](https://void.cat/) - Another public NIP-95 service
|
- **Platform Lightning Address**: Empty by default
|
||||||
- Or host your own NIP-95 compatible service
|
|
||||||
|
|
||||||
Example `.env.local`:
|
### Customizing Configuration
|
||||||
```
|
|
||||||
NEXT_PUBLIC_NOSTR_RELAY_URL=wss://relay.damus.io
|
Configuration is stored in IndexedDB and can be customized through the application settings. The application supports:
|
||||||
NEXT_PUBLIC_NIP95_UPLOAD_URL=https://nostr.build/api/v2/upload
|
- 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
|
## 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
|
- `/lib`: Utilities and Nostr helpers
|
||||||
- `/types`: TypeScript type definitions
|
- `/types`: TypeScript type definitions
|
||||||
- `/hooks`: Custom React hooks
|
- `/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
143
RESUME-DEPLOIEMENT.md
Normal 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
142
SECURITY.md
Normal 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!
|
||||||
@ -11,6 +11,20 @@ interface ArticleCardProps {
|
|||||||
onUnlock?: (article: Article) => void
|
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({
|
function ArticleMeta({
|
||||||
article,
|
article,
|
||||||
error,
|
error,
|
||||||
@ -56,15 +70,7 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps) {
|
|||||||
|
|
||||||
return (
|
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">
|
<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">
|
<ArticleHeader article={article} />
|
||||||
<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>
|
|
||||||
<div className="text-cyber-accent mb-4">
|
<div className="text-cyber-accent mb-4">
|
||||||
<ArticlePreview
|
<ArticlePreview
|
||||||
article={article}
|
article={article}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import React from 'react'
|
||||||
import Image from 'next/image'
|
|
||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
import { useAuthorsProfiles } from '@/hooks/useAuthorsProfiles'
|
|
||||||
import { generateMnemonicIcons } from '@/lib/mnemonicIcons'
|
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
|
import { AuthorFilter } from './AuthorFilter'
|
||||||
|
|
||||||
export type SortOption = 'newest' | 'oldest'
|
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({
|
function SortFilter({
|
||||||
value,
|
value,
|
||||||
@ -279,7 +92,7 @@ function SortFilter({
|
|||||||
<select
|
<select
|
||||||
id="sort"
|
id="sort"
|
||||||
value={value}
|
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"
|
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>
|
<option value="newest" className="bg-cyber-dark">{t('filters.sort.newest')}</option>
|
||||||
|
|||||||
90
components/AuthorFilter.tsx
Normal file
90
components/AuthorFilter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
components/AuthorFilterButton.tsx
Normal file
121
components/AuthorFilterButton.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
224
components/AuthorFilterDropdown.tsx
Normal file
224
components/AuthorFilterDropdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
components/AuthorFilterHooks.tsx
Normal file
86
components/AuthorFilterHooks.tsx
Normal 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 }
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { useNostrAuth } from '@/hooks/useNostrAuth'
|
|||||||
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
||||||
import { ArticleField } from './ArticleField'
|
import { ArticleField } from './ArticleField'
|
||||||
import { ArticleFormButtons } from './ArticleFormButtons'
|
import { ArticleFormButtons } from './ArticleFormButtons'
|
||||||
import { ConnectButton } from './ConnectButton'
|
import { CreateAccountModal } from './CreateAccountModal'
|
||||||
import { ImageUploadField } from './ImageUploadField'
|
import { ImageUploadField } from './ImageUploadField'
|
||||||
import { PresentationFormHeader } from './PresentationFormHeader'
|
import { PresentationFormHeader } from './PresentationFormHeader'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
@ -193,6 +193,32 @@ function useAuthorPresentationState(pubkey: string | null) {
|
|||||||
return { loading, error, success, draft, setDraft, validationError, handleSubmit }
|
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({
|
function AuthorPresentationFormView({
|
||||||
pubkey,
|
pubkey,
|
||||||
connected,
|
connected,
|
||||||
@ -205,13 +231,7 @@ function AuthorPresentationFormView({
|
|||||||
const state = useAuthorPresentationState(pubkey)
|
const state = useAuthorPresentationState(pubkey)
|
||||||
|
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
return (
|
return <NoAccountView />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (state.success) {
|
if (state.success) {
|
||||||
return <SuccessNotice />
|
return <SuccessNotice />
|
||||||
|
|||||||
@ -4,6 +4,32 @@ import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { t } from '@/lib/i18n'
|
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() {
|
export function ConditionalPublishButton() {
|
||||||
const { connected, pubkey } = useNostrAuth()
|
const { connected, pubkey } = useNostrAuth()
|
||||||
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
|
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
|
||||||
@ -22,41 +48,16 @@ export function ConditionalPublishButton() {
|
|||||||
}, [connected, pubkey, checkPresentationExists])
|
}, [connected, pubkey, checkPresentationExists])
|
||||||
|
|
||||||
if (!connected || !pubkey) {
|
if (!connected || !pubkey) {
|
||||||
return (
|
return <CreateAuthorPageLink />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPresentation === null) {
|
if (hasPresentation === null) {
|
||||||
return (
|
return <LoadingButton />
|
||||||
<div className="px-4 py-2 bg-neon-cyan/20 text-neon-cyan rounded-lg text-sm font-medium">
|
|
||||||
{t('nav.loading')}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPresentation) {
|
if (!hasPresentation) {
|
||||||
return (
|
return <CreateAuthorPageLink />
|
||||||
<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 (
|
return <PublishLink />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,51 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||||
import { ConnectedUserMenu } from './ConnectedUserMenu'
|
import { ConnectedUserMenu } from './ConnectedUserMenu'
|
||||||
|
import { CreateAccountModal } from './CreateAccountModal'
|
||||||
|
import { UnlockAccountModal } from './UnlockAccountModal'
|
||||||
|
import type { NostrProfile } from '@/types/nostr'
|
||||||
|
|
||||||
function ConnectForm({ onConnect, loading, error }: {
|
function ConnectForm({
|
||||||
onConnect: () => void
|
onCreateAccount,
|
||||||
|
onUnlock,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
onCreateAccount: () => void
|
||||||
|
onUnlock: () => void
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={onCreateAccount}
|
||||||
void onConnect()
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
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"
|
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>
|
</button>
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectButton() {
|
function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showCreateModal: boolean, showUnlockModal: boolean, connect: () => Promise<void>) {
|
||||||
const { connected, pubkey, profile, loading, error, connect, disconnect } = useNostrAuth()
|
useEffect(() => {
|
||||||
|
if (accountExists === true && !pubkey && !showCreateModal && !showUnlockModal) {
|
||||||
|
void connect()
|
||||||
|
}
|
||||||
|
}, [accountExists, pubkey, showCreateModal, showUnlockModal, connect])
|
||||||
|
}
|
||||||
|
|
||||||
if (connected && pubkey) {
|
function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }) {
|
||||||
return (
|
return (
|
||||||
<ConnectedUserMenu
|
<ConnectedUserMenu
|
||||||
pubkey={pubkey}
|
pubkey={pubkey}
|
||||||
@ -36,15 +56,117 @@ export function ConnectButton() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean; error: string | null; onUnlock: () => void; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ConnectForm
|
<ConnectForm
|
||||||
onConnect={() => {
|
onCreateAccount={() => {}}
|
||||||
void connect()
|
onUnlock={onUnlock}
|
||||||
}}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
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 (
|
||||||
|
<DisconnectedState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
showCreateModal={showCreateModal}
|
||||||
|
showUnlockModal={showUnlockModal}
|
||||||
|
setShowCreateModal={setShowCreateModal}
|
||||||
|
setShowUnlockModal={setShowUnlockModal}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
159
components/CreateAccountModal.tsx
Normal file
159
components/CreateAccountModal.tsx
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
148
components/CreateAccountModalComponents.tsx
Normal file
148
components/CreateAccountModalComponents.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
components/CreateAccountModalSteps.tsx
Normal file
94
components/CreateAccountModalSteps.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,6 +2,45 @@ import { useEffect, useState } from 'react'
|
|||||||
import { estimatePlatformFunds } from '@/lib/fundingCalculation'
|
import { estimatePlatformFunds } from '@/lib/fundingCalculation'
|
||||||
import { t } from '@/lib/i18n'
|
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() {
|
export function FundingGauge() {
|
||||||
const [stats, setStats] = useState(estimatePlatformFunds())
|
const [stats, setStats] = useState(estimatePlatformFunds())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -30,38 +69,10 @@ export function FundingGauge() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressPercent = Math.min(100, stats.progressPercent)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
|
<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>
|
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('home.funding.title')}</h2>
|
||||||
|
<FundingStats stats={stats} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,10 +59,10 @@ function HomeIntroSection() {
|
|||||||
<div className="mt-12 mb-8">
|
<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">
|
<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">
|
<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'eau par <strong className="text-neon-green">800 sats</strong> (moins 100 sats et frais de transaction).
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-2">
|
<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'auteur pour <strong className="text-neon-green">0.046 BTC</strong> (moins 0.004 BTC et frais de transaction).
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
Les avis sont remerciables pour <strong className="text-neon-green">70 sats</strong> (moins 21 sats et frais de transaction).
|
Les avis sont remerciables pour <strong className="text-neon-green">70 sats</strong> (moins 21 sats et frais de transaction).
|
||||||
|
|||||||
@ -11,7 +11,87 @@ interface ImageUploadFieldProps {
|
|||||||
helpText?: string | undefined
|
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 [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
@ -25,12 +105,7 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
|
|||||||
setUploading(true)
|
setUploading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const media = await uploadNip95Media(file)
|
await processFileUpload(file, onChange, setError)
|
||||||
if (media.type === 'image') {
|
|
||||||
onChange(media.url)
|
|
||||||
} else {
|
|
||||||
setError(t('presentation.field.picture.error.imagesOnly'))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : t('presentation.field.picture.error.uploadFailed'))
|
setError(e instanceof Error ? e.message : t('presentation.field.picture.error.uploadFailed'))
|
||||||
} finally {
|
} 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 displayLabel = label ?? t('presentation.field.picture')
|
||||||
const displayHelpText = helpText ?? t('presentation.field.picture.help')
|
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">
|
<label htmlFor={id} className="block text-sm font-medium text-neon-cyan">
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
</label>
|
</label>
|
||||||
{value && (
|
{value && <ImagePreview value={value} />}
|
||||||
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20">
|
<ImageUploadControls
|
||||||
<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}
|
id={id}
|
||||||
type="file"
|
uploading={uploading}
|
||||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
value={value}
|
||||||
className="hidden"
|
onChange={onChange}
|
||||||
onChange={handleFileSelect}
|
onFileSelect={handleFileSelect}
|
||||||
disabled={uploading}
|
|
||||||
/>
|
/>
|
||||||
{value && (
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
<button
|
{displayHelpText && <p className="text-sm text-cyber-accent">{displayHelpText}</p>}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,29 @@ import { setLocale, getLocale, type Locale } from '@/lib/i18n'
|
|||||||
|
|
||||||
const LOCALE_STORAGE_KEY = 'zapwall-locale'
|
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() {
|
export function LanguageSelector() {
|
||||||
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
|
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
|
||||||
|
|
||||||
@ -27,26 +50,8 @@ export function LanguageSelector() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<LocaleButton locale="fr" label="FR" currentLocale={currentLocale} onClick={handleLocaleChange} />
|
||||||
onClick={() => handleLocaleChange('fr')}
|
<LocaleButton locale="en" label="EN" currentLocale={currentLocale} onClick={handleLocaleChange} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
159
components/UnlockAccountModal.tsx
Normal file
159
components/UnlockAccountModal.tsx
Normal 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
204
deploy.sh
Normal 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
133
docs/DOCUMENTATION.md
Normal 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
200
docs/README-DEPLOYMENT.md
Normal 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
75
docs/README.md
Normal 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
611
docs/deployment.md
Normal 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*
|
||||||
@ -234,7 +234,7 @@ Oui, vous pouvez rechercher par titre, aperçu ou contenu. Vous pouvez égalemen
|
|||||||
|
|
||||||
### Quel relay Nostr est utilisé ?
|
### 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 ?
|
### 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 ?
|
### 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
66
docs/quick-reference.md
Normal 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`
|
||||||
@ -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
372
docs/scripts-reference.md
Normal 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*
|
||||||
170
features/open-source-setup.md
Normal file
170
features/open-source-setup.md
Normal 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
|
||||||
60
fixKnowledge/letsencrypt-certificates-setup.md
Normal file
60
fixKnowledge/letsencrypt-certificates-setup.md
Normal 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
|
||||||
57
fixKnowledge/nginx-conf-d-not-loaded.md
Normal file
57
fixKnowledge/nginx-conf-d-not-loaded.md
Normal 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
|
||||||
@ -6,12 +6,16 @@ export function useNostrAuth() {
|
|||||||
const [state, setState] = useState<NostrConnectState>(nostrAuthService.getState())
|
const [state, setState] = useState<NostrConnectState>(nostrAuthService.getState())
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [accountExists, setAccountExists] = useState<boolean | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = nostrAuthService.subscribe((newState) => {
|
const unsubscribe = nostrAuthService.subscribe((newState) => {
|
||||||
setState(newState)
|
setState(newState)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Check if account exists on mount
|
||||||
|
nostrAuthService.accountExists().then(setAccountExists).catch(() => setAccountExists(false))
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -44,5 +48,7 @@ export function useNostrAuth() {
|
|||||||
error,
|
error,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
accountExists,
|
||||||
|
isUnlocked: nostrAuthService.isUnlocked(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,21 @@ function arrayBufferToHex(buffer: ArrayBuffer): string {
|
|||||||
* Encrypt article content with AES-GCM
|
* Encrypt article content with AES-GCM
|
||||||
* Returns encrypted content, IV, and the encryption key
|
* 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<{
|
export async function encryptArticleContent(content: string): Promise<{
|
||||||
encryptedContent: string
|
encryptedContent: string
|
||||||
key: string
|
key: string
|
||||||
@ -65,39 +80,21 @@ export async function encryptArticleContent(content: string): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const key = generateEncryptionKey()
|
const key = generateEncryptionKey()
|
||||||
const iv = generateIV()
|
const iv = generateIV()
|
||||||
const keyBuffer = hexToArrayBuffer(key)
|
const cryptoKey = await prepareEncryptionKey(key)
|
||||||
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
keyBuffer,
|
|
||||||
{ name: 'AES-GCM' },
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
)
|
|
||||||
|
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const encodedContent = encoder.encode(content)
|
const encodedContent = encoder.encode(content)
|
||||||
|
const { view: ivView, buffer: ivBuffer } = prepareIV(iv)
|
||||||
const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength)
|
|
||||||
const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength)
|
|
||||||
ivView.set(iv)
|
|
||||||
|
|
||||||
const encryptedBuffer = await crypto.subtle.encrypt(
|
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||||
{
|
{ name: 'AES-GCM', iv: ivView as Uint8Array<ArrayBuffer> },
|
||||||
name: 'AES-GCM',
|
|
||||||
iv: ivView,
|
|
||||||
},
|
|
||||||
cryptoKey,
|
cryptoKey,
|
||||||
encodedContent
|
encodedContent
|
||||||
)
|
)
|
||||||
|
|
||||||
const encryptedContent = arrayBufferToHex(encryptedBuffer)
|
|
||||||
const ivHex = arrayBufferToHex(ivView.buffer)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encryptedContent,
|
encryptedContent: arrayBufferToHex(encryptedBuffer),
|
||||||
key,
|
key,
|
||||||
iv: ivHex,
|
iv: arrayBufferToHex(ivBuffer),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -163,22 +163,7 @@ function buildReviewEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishArticleUpdate(
|
function buildUpdateTags(draft: ArticleDraft, originalArticleId: string, newCategory: 'sciencefiction' | 'research') {
|
||||||
originalArticleId: string,
|
|
||||||
draft: ArticleDraft,
|
|
||||||
authorPubkey: string,
|
|
||||||
authorPrivateKey?: string
|
|
||||||
): 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({
|
const updateTags = buildTags({
|
||||||
type: 'publication',
|
type: 'publication',
|
||||||
category: newCategory,
|
category: newCategory,
|
||||||
@ -190,9 +175,21 @@ export async function publishArticleUpdate(
|
|||||||
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
||||||
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add reference to original article
|
|
||||||
updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
|
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)
|
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
|
||||||
if (!publishedEvent) {
|
if (!publishedEvent) {
|
||||||
@ -206,6 +203,17 @@ export async function publishArticleUpdate(
|
|||||||
success: true,
|
success: true,
|
||||||
originalArticleId,
|
originalArticleId,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishArticleUpdate(
|
||||||
|
originalArticleId: string,
|
||||||
|
draft: ArticleDraft,
|
||||||
|
authorPubkey: string,
|
||||||
|
authorPrivateKey?: string
|
||||||
|
): Promise<ArticleUpdateResult> {
|
||||||
|
try {
|
||||||
|
ensureKeys(authorPubkey, authorPrivateKey)
|
||||||
|
return await publishUpdate(draft, authorPubkey, originalArticleId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
|
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +1,13 @@
|
|||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
import type { AlbyInvoice } from '@/types/alby'
|
|
||||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||||
import type { MediaRef } from '@/types/nostr'
|
import type { AlbyInvoice } from '@/types/alby'
|
||||||
import {
|
import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage'
|
||||||
storePrivateContent,
|
|
||||||
getStoredPrivateContent,
|
|
||||||
getStoredInvoice,
|
|
||||||
removeStoredPrivateContent,
|
|
||||||
} from './articleStorage'
|
|
||||||
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
|
||||||
import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers'
|
import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers'
|
||||||
import {
|
import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
||||||
encryptArticleContent,
|
import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation'
|
||||||
encryptDecryptionKey,
|
import { buildFailure, encryptAndPublish } from './articlePublisherPublish'
|
||||||
} from './articleEncryption'
|
|
||||||
|
|
||||||
export interface ArticleDraft {
|
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for publishing articles on Nostr
|
* Service for publishing articles on Nostr
|
||||||
@ -51,61 +16,39 @@ export interface PublishedArticle {
|
|||||||
export class ArticlePublisher {
|
export class ArticlePublisher {
|
||||||
// Removed unused siteTag - using new tag system instead
|
// Removed unused siteTag - using new tag system instead
|
||||||
|
|
||||||
private buildFailure(error?: string): PublishedArticle {
|
private async validatePublishRequest(
|
||||||
const base: PublishedArticle = {
|
draft: ArticleDraft,
|
||||||
articleId: '',
|
authorPubkey: string,
|
||||||
previewEventId: '',
|
authorPrivateKey?: string
|
||||||
success: false,
|
): Promise<PublishValidationResult> {
|
||||||
}
|
const keySetup = prepareAuthorKeys(authorPubkey, authorPrivateKey)
|
||||||
return error ? { ...base, error } : base
|
if (!keySetup.success) {
|
||||||
|
return { success: false, error: keySetup.error ?? 'Key setup failed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
|
const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
|
||||||
nostrService.setPublicKey(authorPubkey)
|
if (!authorPrivateKeyForEncryption) {
|
||||||
|
return { success: false, error: 'Private key required for encryption' }
|
||||||
if (authorPrivateKey) {
|
|
||||||
nostrService.setPrivateKey(authorPrivateKey)
|
|
||||||
return { success: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingPrivateKey = nostrService.getPrivateKey()
|
const presentation = await this.getAuthorPresentation(authorPubkey)
|
||||||
if (!existingPrivateKey) {
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: `Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
|
||||||
'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true, authorPrivateKeyForEncryption, category: draft.category }
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -119,67 +62,20 @@ export class ArticlePublisher {
|
|||||||
authorPrivateKey?: string
|
authorPrivateKey?: string
|
||||||
): Promise<PublishedArticle> {
|
): Promise<PublishedArticle> {
|
||||||
try {
|
try {
|
||||||
const keySetup = this.prepareAuthorKeys(authorPubkey, authorPrivateKey)
|
const validation = await this.validatePublishRequest(draft, authorPubkey, authorPrivateKey)
|
||||||
if (!keySetup.success) {
|
if (!validation.success) {
|
||||||
return this.buildFailure(keySetup.error)
|
return buildFailure(validation.error)
|
||||||
}
|
|
||||||
|
|
||||||
const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
|
|
||||||
if (!authorPrivateKeyForEncryption) {
|
|
||||||
return this.buildFailure('Private key required for encryption')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const presentation = await this.getAuthorPresentation(authorPubkey)
|
const presentation = await this.getAuthorPresentation(authorPubkey)
|
||||||
if (!presentation) {
|
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 await encryptAndPublish(draft, authorPubkey, validation.authorPrivateKeyForEncryption, validation.category, presentation.id)
|
||||||
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 }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error publishing article:', 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,24 +104,7 @@ export class ArticlePublisher {
|
|||||||
* Send private content to a user after payment confirmation
|
* Send private content to a user after payment confirmation
|
||||||
* Returns detailed result with message event ID and verification status
|
* Returns detailed result with message event ID and verification status
|
||||||
*/
|
*/
|
||||||
async sendPrivateContent(
|
private logSendResult(result: import('./articlePublisherHelpers').SendContentResult, articleId: string, recipientPubkey: string) {
|
||||||
articleId: string,
|
|
||||||
recipientPubkey: string,
|
|
||||||
authorPrivateKey: string
|
|
||||||
): Promise<import('./articlePublisherHelpers').SendContentResult> {
|
|
||||||
try {
|
|
||||||
const stored = await getStoredPrivateContent(articleId)
|
|
||||||
if (!stored) {
|
|
||||||
const error = 'Private content not found for article'
|
|
||||||
console.error(error, { articleId, recipientPubkey })
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await sendEncryptedContent(articleId, recipientPubkey, stored, authorPrivateKey)
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log('Private content sent successfully', {
|
console.log('Private content sent successfully', {
|
||||||
articleId,
|
articleId,
|
||||||
@ -242,7 +121,23 @@ export class ArticlePublisher {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPrivateContent(
|
||||||
|
articleId: string,
|
||||||
|
recipientPubkey: string,
|
||||||
|
authorPrivateKey: string
|
||||||
|
): Promise<import('./articlePublisherHelpers').SendContentResult> {
|
||||||
|
try {
|
||||||
|
const stored = await getStoredPrivateContent(articleId)
|
||||||
|
if (!stored) {
|
||||||
|
const error = 'Private content not found for article'
|
||||||
|
console.error(error, { articleId, recipientPubkey })
|
||||||
|
return { success: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendEncryptedContent(articleId, recipientPubkey, stored, authorPrivateKey)
|
||||||
|
this.logSendResult(result, articleId, recipientPubkey)
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
@ -252,10 +147,7 @@ export class ArticlePublisher {
|
|||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
return {
|
return { success: false, error: errorMessage }
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,11 +172,11 @@ export class ArticlePublisher {
|
|||||||
nostrService.setPrivateKey(authorPrivateKey)
|
nostrService.setPrivateKey(authorPrivateKey)
|
||||||
|
|
||||||
// Generate event ID before building event (using a temporary ID that will be replaced by Nostr)
|
// 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'))
|
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction'))
|
||||||
|
|
||||||
if (!publishedEvent) {
|
if (!publishedEvent) {
|
||||||
return this.buildFailure('Failed to publish presentation article')
|
return buildFailure('Failed to publish presentation article')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -294,7 +186,7 @@ export class ArticlePublisher {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error publishing presentation article:', 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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,271 +1,3 @@
|
|||||||
import { nip04, type Event } from 'nostr-tools'
|
export { buildPresentationEvent, parsePresentationEvent, fetchAuthorPresentationFromPool } from './articlePublisherHelpersPresentation'
|
||||||
import { nostrService } from './nostr'
|
export { sendEncryptedContent, type SendContentResult } from './articlePublisherHelpersEncryption'
|
||||||
import type { AuthorPresentationDraft } from './articlePublisher'
|
export { verifyPrivateMessagePublished } from './articlePublisherHelpersVerification'
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
86
lib/articlePublisherHelpersEncryption.ts
Normal file
86
lib/articlePublisherHelpersEncryption.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
89
lib/articlePublisherHelpersPresentation.ts
Normal file
89
lib/articlePublisherHelpersPresentation.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
156
lib/articlePublisherHelpersVerification.ts
Normal file
156
lib/articlePublisherHelpersVerification.ts
Normal 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
|
||||||
|
}
|
||||||
66
lib/articlePublisherPublish.ts
Normal file
66
lib/articlePublisherPublish.ts
Normal 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 }
|
||||||
|
}
|
||||||
31
lib/articlePublisherTypes.ts
Normal file
31
lib/articlePublisherTypes.ts
Normal 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
|
||||||
|
}
|
||||||
39
lib/articlePublisherValidation.ts
Normal file
39
lib/articlePublisherValidation.ts
Normal 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
|
||||||
@ -4,15 +4,9 @@ import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
|||||||
import type { Article } from '@/types/nostr'
|
import type { Article } from '@/types/nostr'
|
||||||
import { parseArticleFromEvent } from './nostrEventParsing'
|
import { parseArticleFromEvent } from './nostrEventParsing'
|
||||||
import { buildTagFilter } from './nostrTagSystem'
|
import { buildTagFilter } from './nostrTagSystem'
|
||||||
|
import { getPrimaryRelaySync } from './config'
|
||||||
|
|
||||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
function createSeriesSubscription(poolWithSub: SimplePoolWithSub, seriesId: string, limit: number) {
|
||||||
|
|
||||||
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 filters = [
|
const filters = [
|
||||||
{
|
{
|
||||||
...buildTagFilter({
|
...buildTagFilter({
|
||||||
@ -22,10 +16,20 @@ export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000,
|
|||||||
limit,
|
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) => {
|
return new Promise<Article[]>((resolve) => {
|
||||||
const results: Article[] = []
|
const results: Article[] = []
|
||||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
|
||||||
let finished = false
|
let finished = false
|
||||||
|
|
||||||
const done = () => {
|
const done = () => {
|
||||||
|
|||||||
@ -22,6 +22,28 @@ export class AutomaticTransferService {
|
|||||||
* Transfer author portion after article payment
|
* Transfer author portion after article payment
|
||||||
* Creates a Lightning invoice from the platform to the author
|
* 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(
|
async transferAuthorPortion(
|
||||||
authorLightningAddress: string,
|
authorLightningAddress: string,
|
||||||
articleId: string,
|
articleId: string,
|
||||||
@ -40,23 +62,8 @@ export class AutomaticTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In a real implementation, this would:
|
this.logTransferRequired('article', articleId, articlePubkey, split.author, authorLightningAddress, split.platform)
|
||||||
// 1. Create a Lightning invoice from platform to author
|
this.trackTransferRequirement('article', articleId, articlePubkey, split.author, authorLightningAddress)
|
||||||
// 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)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -64,19 +71,13 @@ export class AutomaticTransferService {
|
|||||||
recipient: authorLightningAddress,
|
recipient: authorLightningAddress,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
console.error('Error transferring author portion', {
|
console.error('Error transferring author portion', {
|
||||||
articleId,
|
articleId,
|
||||||
articlePubkey,
|
articlePubkey,
|
||||||
error: errorMessage,
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
return {
|
return this.buildTransferError(error, authorLightningAddress)
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
amount: 0,
|
|
||||||
recipient: authorLightningAddress,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,16 +102,8 @@ export class AutomaticTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Automatic transfer required for review', {
|
this.logTransferRequired('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress, split.platform)
|
||||||
reviewId,
|
this.trackTransferRequirement('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress)
|
||||||
reviewerPubkey,
|
|
||||||
amount: split.reviewer,
|
|
||||||
recipient: reviewerLightningAddress,
|
|
||||||
platformCommission: split.platform,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await this.trackTransferRequirement('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -118,19 +111,13 @@ export class AutomaticTransferService {
|
|||||||
recipient: reviewerLightningAddress,
|
recipient: reviewerLightningAddress,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
console.error('Error transferring reviewer portion', {
|
console.error('Error transferring reviewer portion', {
|
||||||
reviewId,
|
reviewId,
|
||||||
reviewerPubkey,
|
reviewerPubkey,
|
||||||
error: errorMessage,
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
return {
|
return this.buildTransferError(error, reviewerLightningAddress)
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
amount: 0,
|
|
||||||
recipient: reviewerLightningAddress,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,13 +125,13 @@ export class AutomaticTransferService {
|
|||||||
* Track transfer requirement for later processing
|
* Track transfer requirement for later processing
|
||||||
* In production, this would be stored in a database or queue
|
* In production, this would be stored in a database or queue
|
||||||
*/
|
*/
|
||||||
private async trackTransferRequirement(
|
private trackTransferRequirement(
|
||||||
type: 'article' | 'review',
|
type: 'article' | 'review',
|
||||||
id: string,
|
id: string,
|
||||||
recipientPubkey: string,
|
recipientPubkey: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
recipientAddress: string
|
recipientAddress: string
|
||||||
): Promise<void> {
|
): void {
|
||||||
// In production, this would:
|
// In production, this would:
|
||||||
// 1. Store in a database/queue for processing
|
// 1. Store in a database/queue for processing
|
||||||
// 2. Trigger automatic transfer via platform's Lightning node
|
// 2. Trigger automatic transfer via platform's Lightning node
|
||||||
|
|||||||
97
lib/config.ts
Normal file
97
lib/config.ts
Normal 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
280
lib/configStorage.ts
Normal 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
80
lib/configStorageNip95.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
80
lib/configStorageRelays.ts
Normal file
80
lib/configStorageRelays.ts
Normal 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
38
lib/configStorageSync.ts
Normal 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
51
lib/configStorageTypes.ts
Normal 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 = ''
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||||
|
import { getPrimaryRelaySync } from './config'
|
||||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
|
||||||
|
|
||||||
export interface ContentDeliveryStatus {
|
export interface ContentDeliveryStatus {
|
||||||
messageEventId: string | null
|
messageEventId: string | null
|
||||||
@ -15,28 +14,7 @@ export interface ContentDeliveryStatus {
|
|||||||
* Verify that private content was successfully delivered to recipient
|
* Verify that private content was successfully delivered to recipient
|
||||||
* Checks multiple aspects to ensure delivery certainty
|
* Checks multiple aspects to ensure delivery certainty
|
||||||
*/
|
*/
|
||||||
export async function verifyContentDelivery(
|
function createContentDeliveryFilters(authorPubkey: string, recipientPubkey: string, articleId: string, messageEventId: string) {
|
||||||
articleId: string,
|
|
||||||
authorPubkey: string,
|
|
||||||
recipientPubkey: string,
|
|
||||||
messageEventId: string
|
|
||||||
): Promise<ContentDeliveryStatus> {
|
|
||||||
const status: ContentDeliveryStatus = {
|
|
||||||
messageEventId,
|
|
||||||
published: false,
|
|
||||||
verifiedOnRelay: false,
|
|
||||||
retrievable: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pool = nostrService.getPool()
|
|
||||||
if (!pool) {
|
|
||||||
status.error = 'Pool not initialized'
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
const poolWithSub = pool as SimplePoolWithSub
|
|
||||||
|
|
||||||
const filters: Array<{
|
const filters: Array<{
|
||||||
kinds: number[]
|
kinds: number[]
|
||||||
ids?: string[]
|
ids?: string[]
|
||||||
@ -57,20 +35,15 @@ export async function verifyContentDelivery(
|
|||||||
if (messageEventId && filters[0]) {
|
if (messageEventId && filters[0]) {
|
||||||
filters[0].ids = [messageEventId]
|
filters[0].ids = [messageEventId]
|
||||||
}
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
function setupContentDeliveryHandlers(
|
||||||
let resolved = false
|
sub: SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
|
||||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
status: ContentDeliveryStatus,
|
||||||
|
finalize: (result: ContentDeliveryStatus) => void,
|
||||||
const finalize = (result: ContentDeliveryStatus) => {
|
isResolved: () => boolean
|
||||||
if (resolved) {
|
): void {
|
||||||
return
|
|
||||||
}
|
|
||||||
resolved = true
|
|
||||||
sub.unsub()
|
|
||||||
resolve(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
sub.on('event', (event) => {
|
sub.on('event', (event) => {
|
||||||
status.published = true
|
status.published = true
|
||||||
status.verifiedOnRelay = true
|
status.verifiedOnRelay = true
|
||||||
@ -87,17 +60,73 @@ export async function verifyContentDelivery(
|
|||||||
})
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!resolved) {
|
if (!isResolved()) {
|
||||||
if (!status.published) {
|
if (!status.published) {
|
||||||
status.error = 'Timeout waiting for message verification'
|
status.error = 'Timeout waiting for message verification'
|
||||||
}
|
}
|
||||||
finalize(status)
|
finalize(status)
|
||||||
}
|
}
|
||||||
}, 5000)
|
}, 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,
|
||||||
|
messageEventId: string
|
||||||
|
): Promise<ContentDeliveryStatus> {
|
||||||
|
const status: ContentDeliveryStatus = {
|
||||||
|
messageEventId,
|
||||||
|
published: false,
|
||||||
|
verifiedOnRelay: false,
|
||||||
|
retrievable: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pool = nostrService.getPool()
|
||||||
|
if (!pool) {
|
||||||
|
status.error = 'Pool not initialized'
|
||||||
|
return Promise.resolve(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolWithSub = pool as SimplePoolWithSub
|
||||||
|
const sub = createContentDeliverySubscription(poolWithSub, authorPubkey, recipientPubkey, articleId, messageEventId)
|
||||||
|
return createContentDeliveryPromise(sub, status)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.error = error instanceof Error ? error.message : 'Unknown error'
|
status.error = error instanceof Error ? error.message : 'Unknown error'
|
||||||
return status
|
return Promise.resolve(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,50 +22,53 @@ export interface FundingStats {
|
|||||||
* This is an approximation based on commission rates
|
* This is an approximation based on commission rates
|
||||||
* Actual calculation would require querying all transactions
|
* Actual calculation would require querying all transactions
|
||||||
*/
|
*/
|
||||||
export async function calculatePlatformFunds(_authorPubkeys: string[]): Promise<FundingStats> {
|
async function calculateArticleCommissions(): Promise<number> {
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
const articleCommissions = await aggregateZapSats({
|
const articleCommissions = await aggregateZapSats({
|
||||||
authorPubkey: '', // Empty to get all
|
authorPubkey: '', // Empty to get all
|
||||||
kindType: 'purchase',
|
kindType: 'purchase',
|
||||||
})
|
})
|
||||||
// Estimate: assume 100 sats commission per purchase (800 total, 700 author, 100 platform)
|
return Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
|
||||||
// This is simplified - actual calculation would need to track each payment
|
|
||||||
totalSats += Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error calculating article commissions:', e)
|
console.error('Error calculating article commissions:', e)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate review commissions (from zap receipts with kind_type: review_tip)
|
async function calculateReviewCommissions(): Promise<number> {
|
||||||
// Each review tip is 70 sats, platform gets 21 sats
|
|
||||||
try {
|
try {
|
||||||
const reviewCommissions = await aggregateZapSats({
|
const reviewCommissions = await aggregateZapSats({
|
||||||
authorPubkey: '', // Empty to get all
|
authorPubkey: '', // Empty to get all
|
||||||
kindType: 'review_tip',
|
kindType: 'review_tip',
|
||||||
})
|
})
|
||||||
// Estimate: assume 21 sats commission per review tip (70 total, 49 reviewer, 21 platform)
|
return Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
|
||||||
totalSats += Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error calculating review commissions:', e)
|
console.error('Error calculating review commissions:', e)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate sponsoring commissions (from zap receipts with kind_type: sponsoring)
|
async function calculateSponsoringCommissions(): Promise<number> {
|
||||||
// Each sponsoring is 0.046 BTC, platform gets 0.004 BTC (400,000 sats)
|
|
||||||
try {
|
try {
|
||||||
const sponsoringCommissions = await aggregateZapSats({
|
const sponsoringCommissions = await aggregateZapSats({
|
||||||
authorPubkey: '', // Empty to get all
|
authorPubkey: '', // Empty to get all
|
||||||
kindType: 'sponsoring',
|
kindType: 'sponsoring',
|
||||||
})
|
})
|
||||||
// Estimate: assume 400,000 sats commission per sponsoring (4,600,000 total, 4,200,000 author, 400,000 platform)
|
return Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
|
||||||
totalSats += Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error calculating sponsoring commissions:', 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 totalBTC = totalSats / 100_000_000
|
||||||
const progressPercent = Math.min(100, (totalBTC / FUNDING_TARGET_BTC) * 100)
|
const progressPercent = Math.min(100, (totalBTC / FUNDING_TARGET_BTC) * 100)
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function getLocale(): Locale {
|
|||||||
* Load translations from a flat text file
|
* Load translations from a flat text file
|
||||||
* Format: key=value (one per line, empty lines and lines starting with # are ignored)
|
* 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 translationsMap: Translations = {}
|
||||||
|
|
||||||
const lines = translationsText.split('\n')
|
const lines = translationsText.split('\n')
|
||||||
|
|||||||
165
lib/keyManagement.ts
Normal file
165
lib/keyManagement.ts
Normal 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()
|
||||||
115
lib/keyManagementEncryption.ts
Normal file
115
lib/keyManagementEncryption.ts
Normal 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)
|
||||||
|
}
|
||||||
36
lib/keyManagementRecovery.ts
Normal file
36
lib/keyManagementRecovery.ts
Normal 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
|
||||||
|
}
|
||||||
62
lib/keyManagementStorage.ts
Normal file
62
lib/keyManagementStorage.ts
Normal 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')
|
||||||
|
}
|
||||||
@ -1,36 +1,14 @@
|
|||||||
import { calculateSponsoringSplit } from './platformCommissions'
|
import type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
|
||||||
import { PLATFORM_BITCOIN_ADDRESS } from './platformConfig'
|
import { getTransaction } from './mempoolSpaceApi'
|
||||||
|
import { verifySponsoringTransaction } from './mempoolSpaceVerification'
|
||||||
|
import { waitForConfirmation } from './mempoolSpaceConfirmation'
|
||||||
|
|
||||||
const MEMPOOL_API_BASE = 'https://mempool.space/api'
|
export type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mempool.space API service
|
||||||
|
* Used to verify Bitcoin mainnet transactions for sponsoring payments
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Mempool.space API service
|
* Mempool.space API service
|
||||||
* Used to verify Bitcoin mainnet transactions for sponsoring payments
|
* Used to verify Bitcoin mainnet transactions for sponsoring payments
|
||||||
@ -40,27 +18,7 @@ export class MempoolSpaceService {
|
|||||||
* Fetch transaction from mempool.space
|
* Fetch transaction from mempool.space
|
||||||
*/
|
*/
|
||||||
async getTransaction(txid: string): Promise<MempoolTransaction | null> {
|
async getTransaction(txid: string): Promise<MempoolTransaction | null> {
|
||||||
try {
|
return await getTransaction(txid)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -71,115 +29,7 @@ export class MempoolSpaceService {
|
|||||||
txid: string,
|
txid: string,
|
||||||
authorMainnetAddress: string
|
authorMainnetAddress: string
|
||||||
): Promise<TransactionVerificationResult> {
|
): Promise<TransactionVerificationResult> {
|
||||||
try {
|
return await verifySponsoringTransaction(txid, authorMainnetAddress)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -191,42 +41,7 @@ export class MempoolSpaceService {
|
|||||||
timeout: number = 600000, // 10 minutes
|
timeout: number = 600000, // 10 minutes
|
||||||
interval: number = 10000 // 10 seconds
|
interval: number = 10000 // 10 seconds
|
||||||
): Promise<TransactionVerificationResult | null> {
|
): Promise<TransactionVerificationResult | null> {
|
||||||
const startTime = Date.now()
|
return await waitForConfirmation(txid, timeout, interval)
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
lib/mempoolSpaceApi.ts
Normal file
43
lib/mempoolSpaceApi.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/mempoolSpaceConfirmation.ts
Normal file
61
lib/mempoolSpaceConfirmation.ts
Normal 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
27
lib/mempoolSpaceTypes.ts
Normal 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
|
||||||
|
}
|
||||||
169
lib/mempoolSpaceVerification.ts
Normal file
169
lib/mempoolSpaceVerification.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { MediaRef } from '@/types/nostr'
|
import type { MediaRef } from '@/types/nostr'
|
||||||
|
import { getPrimaryNip95Api } from './config'
|
||||||
|
|
||||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||||
const MAX_VIDEO_BYTES = 45 * 1024 * 1024
|
const MAX_VIDEO_BYTES = 45 * 1024 * 1024
|
||||||
@ -36,10 +37,10 @@ export async function uploadNip95Media(file: File): Promise<MediaRef> {
|
|||||||
assertBrowser()
|
assertBrowser()
|
||||||
const mediaType = validateFile(file)
|
const mediaType = validateFile(file)
|
||||||
|
|
||||||
const endpoint = process.env.NEXT_PUBLIC_NIP95_UPLOAD_URL
|
const endpoint = await getPrimaryNip95Api()
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
throw new Error(
|
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.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
63
lib/nostr.ts
63
lib/nostr.ts
@ -9,8 +9,8 @@ import {
|
|||||||
} from './nostrPrivateMessages'
|
} from './nostrPrivateMessages'
|
||||||
import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification'
|
import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification'
|
||||||
import { subscribeWithTimeout } from './nostrSubscription'
|
import { subscribeWithTimeout } from './nostrSubscription'
|
||||||
|
import { getPrimaryRelay, getPrimaryRelaySync } from './config'
|
||||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
import { buildTagFilter } from './nostrTagSystem'
|
||||||
|
|
||||||
class NostrService {
|
class NostrService {
|
||||||
private pool: SimplePool | null = null
|
private pool: SimplePool | null = null
|
||||||
@ -77,7 +77,8 @@ class NostrService {
|
|||||||
} as Event
|
} as Event
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pubs = this.pool.publish([RELAY_URL], event)
|
const relayUrl = await getPrimaryRelay()
|
||||||
|
const pubs = this.pool.publish([relayUrl], event)
|
||||||
await Promise.all(pubs)
|
await Promise.all(pubs)
|
||||||
return event
|
return event
|
||||||
} catch (e) {
|
} 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 {
|
subscribeToArticles(callback: (article: Article) => void, limit: number = 100): () => void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
throw new Error('Cannot subscribe on server side')
|
throw new Error('Cannot subscribe on server side')
|
||||||
@ -98,20 +112,8 @@ class NostrService {
|
|||||||
throw new Error('Pool not initialized')
|
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 pool = this.pool as SimplePoolWithSub
|
||||||
const sub = pool.sub([RELAY_URL], filters)
|
const sub = this.createArticleSubscription(pool, limit)
|
||||||
|
|
||||||
sub.on('event', (event: Event) => {
|
sub.on('event', (event: Event) => {
|
||||||
try {
|
try {
|
||||||
@ -152,39 +154,32 @@ class NostrService {
|
|||||||
* then retrieves the decryption key from private messages,
|
* then retrieves the decryption key from private messages,
|
||||||
* and finally decrypts the content
|
* 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> {
|
async getDecryptedArticleContent(eventId: string, authorPubkey: string): Promise<string | null> {
|
||||||
if (!this.privateKey || !this.pool || !this.publicKey) {
|
if (!this.privateKey || !this.pool || !this.publicKey) {
|
||||||
throw new Error('Private key not set or pool not initialized')
|
throw new Error('Private key not set or pool not initialized')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the raw event to retrieve the encrypted content
|
|
||||||
const event = await this.getEventById(eventId)
|
const event = await this.getEventById(eventId)
|
||||||
if (!event) {
|
if (!event) {
|
||||||
console.error('Event not found', { eventId, authorPubkey })
|
console.error('Event not found', { eventId, authorPubkey })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedContent = event.content
|
const decryptionKey = await this.retrieveDecryptionKey(eventId, authorPubkey)
|
||||||
|
|
||||||
// Try to get the decryption key from private messages
|
|
||||||
const decryptionKey = await getDecryptionKey(
|
|
||||||
this.pool,
|
|
||||||
eventId,
|
|
||||||
authorPubkey,
|
|
||||||
this.privateKey,
|
|
||||||
this.publicKey
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!decryptionKey) {
|
if (!decryptionKey) {
|
||||||
console.warn('Decryption key not found in private messages', { eventId, authorPubkey })
|
console.warn('Decryption key not found in private messages', { eventId, authorPubkey })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the content using the key
|
return await decryptArticleContentWithKey(event.content, decryptionKey)
|
||||||
const decryptedContent = await decryptArticleContentWithKey(encryptedContent, decryptionKey)
|
|
||||||
|
|
||||||
return decryptedContent
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error decrypting article content', {
|
console.error('Error decrypting article content', {
|
||||||
eventId,
|
eventId,
|
||||||
@ -239,6 +234,8 @@ class NostrService {
|
|||||||
throw new Error('Private key not set')
|
throw new Error('Private key not set')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const relayUrl = await getPrimaryRelay()
|
||||||
|
|
||||||
const zapRequest: EventTemplate = {
|
const zapRequest: EventTemplate = {
|
||||||
kind: 9734, // Zap request
|
kind: 9734, // Zap request
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
@ -246,7 +243,7 @@ class NostrService {
|
|||||||
['p', targetPubkey],
|
['p', targetPubkey],
|
||||||
['e', targetEventId],
|
['e', targetEventId],
|
||||||
['amount', amount.toString()],
|
['amount', amount.toString()],
|
||||||
['relays', RELAY_URL],
|
['relays', relayUrl],
|
||||||
],
|
],
|
||||||
content: '',
|
content: '',
|
||||||
}
|
}
|
||||||
|
|||||||
134
lib/nostrAuth.ts
134
lib/nostrAuth.ts
@ -1,9 +1,10 @@
|
|||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
|
import { keyManagementService } from './keyManagement'
|
||||||
import type { NostrConnectState } from '@/types/nostr'
|
import type { NostrConnectState } from '@/types/nostr'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nostr authentication service using Alby (NIP-07)
|
* Nostr authentication service using local key management
|
||||||
* Alby exposes window.nostr API for Nostr authentication and signing
|
* Keys are stored encrypted in IndexedDB and decrypted using recovery phrase
|
||||||
*/
|
*/
|
||||||
export class NostrAuthService {
|
export class NostrAuthService {
|
||||||
private state: NostrConnectState = {
|
private state: NostrConnectState = {
|
||||||
@ -13,6 +14,7 @@ export class NostrAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private listeners: Set<(state: NostrConnectState) => void> = new Set()
|
private listeners: Set<(state: NostrConnectState) => void> = new Set()
|
||||||
|
private unlockedPrivateKey: string | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@ -34,55 +36,128 @@ export class NostrAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if Alby (window.nostr) is available
|
* Check if account exists
|
||||||
*/
|
*/
|
||||||
isAvailable(): boolean {
|
async accountExists(): Promise<boolean> {
|
||||||
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'
|
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> {
|
async createAccount(privateKey?: string): Promise<{
|
||||||
if (!this.isAvailable()) {
|
recoveryPhrase: string[]
|
||||||
throw new Error('Alby extension not available. Please install Alby browser extension.')
|
npub: string
|
||||||
}
|
publicKey: string
|
||||||
|
}> {
|
||||||
if (!window.nostr) {
|
const result = await keyManagementService.createAccount(privateKey)
|
||||||
throw new Error('window.nostr is not available. Please ensure Alby extension is installed and enabled.')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pubkey = await window.nostr.getPublicKey()
|
|
||||||
if (!pubkey) {
|
|
||||||
throw new Error('Failed to get public key from Alby')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Set public key immediately
|
||||||
this.state = {
|
this.state = {
|
||||||
connected: true,
|
connected: false,
|
||||||
pubkey,
|
pubkey: result.publicKey,
|
||||||
profile: null,
|
profile: null,
|
||||||
}
|
}
|
||||||
nostrService.setPublicKey(pubkey)
|
nostrService.setPublicKey(result.publicKey)
|
||||||
|
this.saveStateToStorage()
|
||||||
|
this.notifyListeners()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock account using recovery phrase
|
||||||
|
*/
|
||||||
|
async unlockAccount(recoveryPhrase: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const keys = await keyManagementService.unlockAccount(recoveryPhrase)
|
||||||
|
|
||||||
|
this.unlockedPrivateKey = keys.privateKey
|
||||||
|
this.state = {
|
||||||
|
connected: true,
|
||||||
|
pubkey: keys.publicKey,
|
||||||
|
profile: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
nostrService.setPublicKey(keys.publicKey)
|
||||||
|
nostrService.setPrivateKey(keys.privateKey)
|
||||||
this.saveStateToStorage()
|
this.saveStateToStorage()
|
||||||
this.notifyListeners()
|
this.notifyListeners()
|
||||||
void this.loadProfile()
|
void this.loadProfile()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error connecting with Alby:', e)
|
console.error('Error unlocking account:', e)
|
||||||
throw new Error(`Failed to connect with Alby: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
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 {
|
disconnect(): void {
|
||||||
|
this.unlockedPrivateKey = null
|
||||||
this.state = {
|
this.state = {
|
||||||
connected: false,
|
connected: false,
|
||||||
pubkey: null,
|
pubkey: null,
|
||||||
profile: 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.saveStateToStorage()
|
||||||
this.notifyListeners()
|
this.notifyListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete account (remove all stored keys)
|
||||||
|
*/
|
||||||
|
async deleteAccount(): Promise<void> {
|
||||||
|
await keyManagementService.deleteAccount()
|
||||||
|
this.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
private async loadProfile(): Promise<void> {
|
private async loadProfile(): Promise<void> {
|
||||||
if (!this.state.pubkey) {
|
if (!this.state.pubkey) {
|
||||||
return
|
return
|
||||||
@ -121,6 +196,7 @@ export class NostrAuthService {
|
|||||||
if (this.state.pubkey) {
|
if (this.state.pubkey) {
|
||||||
nostrService.setPublicKey(this.state.pubkey)
|
nostrService.setPublicKey(this.state.pubkey)
|
||||||
}
|
}
|
||||||
|
// Note: private key is not stored, it must be unlocked with recovery phrase
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading state from storage:', e)
|
console.error('Error loading state from storage:', e)
|
||||||
@ -129,7 +205,13 @@ export class NostrAuthService {
|
|||||||
|
|
||||||
private saveStateToStorage(): void {
|
private saveStateToStorage(): void {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error('Error saving state to storage:', e)
|
console.error('Error saving state to storage:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,20 +99,20 @@ function buildArticle(event: Event, tags: ReturnType<typeof extractTagsFromEvent
|
|||||||
return {
|
return {
|
||||||
id: tags.id ?? event.id,
|
id: tags.id ?? event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
title: (tags.title as string | undefined) ?? 'Untitled',
|
title: tags.title ?? 'Untitled',
|
||||||
preview,
|
preview,
|
||||||
content: '',
|
content: '',
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
zapAmount: (tags.zapAmount as number | undefined) ?? 800,
|
zapAmount: tags.zapAmount ?? 800,
|
||||||
paid: false,
|
paid: false,
|
||||||
...(tags.invoice ? { invoice: tags.invoice as string } : {}),
|
...(tags.invoice ? { invoice: tags.invoice } : {}),
|
||||||
...(tags.paymentHash ? { paymentHash: tags.paymentHash as string } : {}),
|
...(tags.paymentHash ? { paymentHash: tags.paymentHash } : {}),
|
||||||
...(category ? { category } : {}),
|
...(category ? { category } : {}),
|
||||||
...(isPresentation ? { isPresentation: true } : {}),
|
...(isPresentation ? { isPresentation: true } : {}),
|
||||||
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress as string } : {}),
|
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress } : {}),
|
||||||
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring as number } : {}),
|
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring } : {}),
|
||||||
...(tags.seriesId ? { seriesId: tags.seriesId as string } : {}),
|
...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
|
||||||
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl as string } : {}),
|
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
|
||||||
...(tags.type === 'publication' ? { kindType: 'article' as KindType } : tags.type === 'author' ? { kindType: 'article' as KindType } : {}),
|
...(tags.type === 'publication' ? { kindType: 'article' as KindType } : tags.type === 'author' ? { kindType: 'article' as KindType } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { Event, nip04 } from 'nostr-tools'
|
import { Event, nip04 } from 'nostr-tools'
|
||||||
import { SimplePool } from 'nostr-tools'
|
import { SimplePool } from 'nostr-tools'
|
||||||
import { decryptArticleContent, type DecryptionKey } from './articleEncryption'
|
import { decryptArticleContent, type DecryptionKey } from './articleEncryption'
|
||||||
|
import { getPrimaryRelaySync } from './config'
|
||||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
|
||||||
|
|
||||||
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string) {
|
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string) {
|
||||||
return [
|
return [
|
||||||
@ -39,7 +38,8 @@ export function getPrivateContent(
|
|||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false
|
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) => {
|
const finalize = (result: string | null) => {
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
@ -70,6 +70,37 @@ export function getPrivateContent(
|
|||||||
* Get decryption key for an article from private messages
|
* Get decryption key for an article from private messages
|
||||||
* Returns the decryption key and IV if found
|
* 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(
|
export async function getDecryptionKey(
|
||||||
pool: SimplePool,
|
pool: SimplePool,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
@ -83,7 +114,8 @@ export async function getDecryptionKey(
|
|||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false
|
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) => {
|
const finalize = (result: DecryptionKey | null) => {
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
@ -94,25 +126,8 @@ export async function getDecryptionKey(
|
|||||||
resolve(result)
|
resolve(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
sub.on('event', async (event: Event) => {
|
sub.on('event', (event: Event) => {
|
||||||
try {
|
handleDecryptionKeyEvent(event, recipientPrivateKey, finalize)
|
||||||
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('eose', () => finalize(null))
|
sub.on('eose', () => finalize(null))
|
||||||
setTimeout(() => finalize(null), 5000)
|
setTimeout(() => finalize(null), 5000)
|
||||||
|
|||||||
@ -11,22 +11,18 @@ export class NostrRemoteSigner {
|
|||||||
/**
|
/**
|
||||||
* Sign an event template using Alby (window.nostr)
|
* Sign an event template using Alby (window.nostr)
|
||||||
*/
|
*/
|
||||||
async signEvent(eventTemplate: EventTemplate): Promise<Event | null> {
|
private buildUnsignedEvent(eventTemplate: EventTemplate, pubkey: string): EventTemplate & { pubkey: string } {
|
||||||
// Get the event hash first
|
return {
|
||||||
const pubkey = nostrService.getPublicKey()
|
|
||||||
if (!pubkey) {
|
|
||||||
throw new Error('Public key required for signing. Please connect with Alby.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsignedEvent = {
|
|
||||||
pubkey,
|
pubkey,
|
||||||
...eventTemplate,
|
...eventTemplate,
|
||||||
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
|
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
|
||||||
}
|
}
|
||||||
const eventId = getEventHash(unsignedEvent)
|
}
|
||||||
|
|
||||||
// Use Alby (window.nostr) for signing
|
private async signWithAlby(unsignedEvent: EventTemplate & { pubkey: string }): Promise<Event | null> {
|
||||||
if (typeof window !== 'undefined' && window.nostr) {
|
if (typeof window === 'undefined' || !window.nostr) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const signedEvent = await window.nostr.signEvent({
|
const signedEvent = await window.nostr.signEvent({
|
||||||
kind: unsignedEvent.kind,
|
kind: unsignedEvent.kind,
|
||||||
@ -41,22 +37,32 @@ export class NostrRemoteSigner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to private key signing (should not happen if Alby is properly connected)
|
private signWithPrivateKey(unsignedEvent: EventTemplate & { pubkey: string }): Event {
|
||||||
const privateKey = nostrService.getPrivateKey()
|
const privateKey = nostrService.getPrivateKey()
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
throw new Error(
|
throw new Error('Alby extension required for signing. Please install and connect Alby browser extension.')
|
||||||
'Alby extension required for signing. ' +
|
|
||||||
'Please install and connect Alby browser extension.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
const eventId = getEventHash(unsignedEvent)
|
||||||
const event = {
|
return {
|
||||||
...unsignedEvent,
|
...unsignedEvent,
|
||||||
id: eventId,
|
id: eventId,
|
||||||
sig: signEvent(unsignedEvent, privateKey),
|
sig: signEvent(unsignedEvent, privateKey),
|
||||||
} as Event
|
} as Event
|
||||||
|
}
|
||||||
|
|
||||||
return event
|
async signEvent(eventTemplate: EventTemplate): Promise<Event | null> {
|
||||||
|
const pubkey = nostrService.getPublicKey()
|
||||||
|
if (!pubkey) {
|
||||||
|
throw new Error('Public key required for signing. Please connect with Alby.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsignedEvent = this.buildUnsignedEvent(eventTemplate, pubkey)
|
||||||
|
const albySigned = await this.signWithAlby(unsignedEvent)
|
||||||
|
if (albySigned) {
|
||||||
|
return albySigned
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.signWithPrivateKey(unsignedEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import type { Event, Filter } from 'nostr-tools'
|
import type { Event, Filter } from 'nostr-tools'
|
||||||
import { SimplePool } from 'nostr-tools'
|
import { SimplePool } from 'nostr-tools'
|
||||||
|
import { getPrimaryRelaySync } from './config'
|
||||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to events with timeout
|
* Subscribe to events with timeout
|
||||||
@ -14,7 +13,8 @@ export function subscribeWithTimeout<T>(
|
|||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const resolved = { value: false }
|
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
|
let timeoutId: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
|||||||
@ -1,274 +1,4 @@
|
|||||||
/**
|
export type { TagType, TagCategory, BaseTags, AuthorTags, SeriesTags, PublicationTags, QuoteTags } from './nostrTagSystemTypes'
|
||||||
* New tag system based on:
|
export { buildTags } from './nostrTagSystemBuild'
|
||||||
* - #paywall: for paid publications
|
export { extractTagsFromEvent } from './nostrTagSystemExtract'
|
||||||
* - #sciencefiction or #research: for category
|
export { buildTagFilter } from './nostrTagSystemFilter'
|
||||||
* - #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
|
|
||||||
}
|
|
||||||
|
|||||||
93
lib/nostrTagSystemBuild.ts
Normal file
93
lib/nostrTagSystemBuild.ts
Normal 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
|
||||||
|
}
|
||||||
75
lib/nostrTagSystemExtract.ts
Normal file
75
lib/nostrTagSystemExtract.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
46
lib/nostrTagSystemFilter.ts
Normal file
46
lib/nostrTagSystemFilter.ts
Normal 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
|
||||||
|
}
|
||||||
58
lib/nostrTagSystemTypes.ts
Normal file
58
lib/nostrTagSystemTypes.ts
Normal 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
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import type { Event } from 'nostr-tools'
|
import type { Event } from 'nostr-tools'
|
||||||
import { SimplePool } from 'nostr-tools'
|
import { SimplePool } from 'nostr-tools'
|
||||||
|
import { getPrimaryRelaySync } from './config'
|
||||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
|
||||||
|
|
||||||
function createZapFilters(targetPubkey: string, targetEventId: string, userPubkey: string) {
|
function createZapFilters(targetPubkey: string, targetEventId: string, userPubkey: string) {
|
||||||
return [
|
return [
|
||||||
@ -30,6 +29,25 @@ async function isValidZapReceipt(
|
|||||||
/**
|
/**
|
||||||
* Check if user has paid for an article by looking for zap receipts
|
* 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(
|
export function checkZapReceipt(
|
||||||
pool: SimplePool,
|
pool: SimplePool,
|
||||||
targetPubkey: string,
|
targetPubkey: string,
|
||||||
@ -43,7 +61,8 @@ export function checkZapReceipt(
|
|||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let resolved = false
|
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) => {
|
const finalize = (value: boolean) => {
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
@ -54,15 +73,9 @@ export function checkZapReceipt(
|
|||||||
resolve(value)
|
resolve(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedRef = { current: resolved }
|
||||||
sub.on('event', (event: Event) => {
|
sub.on('event', (event: Event) => {
|
||||||
if (resolved) {
|
handleZapReceiptEvent(event, targetEventId, targetPubkey, userPubkey, amount, finalize, resolvedRef)
|
||||||
return
|
|
||||||
}
|
|
||||||
void isValidZapReceipt(event, targetEventId, targetPubkey, userPubkey, amount).then((isValid) => {
|
|
||||||
if (isValid) {
|
|
||||||
finalize(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const end = () => finalize(false)
|
const end = () => finalize(false)
|
||||||
|
|||||||
@ -3,8 +3,7 @@ import { nostrService } from './nostr'
|
|||||||
import { zapVerificationService } from './zapVerification'
|
import { zapVerificationService } from './zapVerification'
|
||||||
import type { Notification } from '@/types/notifications'
|
import type { Notification } from '@/types/notifications'
|
||||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||||
|
import { getPrimaryRelaySync } from './config'
|
||||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
|
||||||
|
|
||||||
function createZapReceiptFilters(userPubkey: string) {
|
function createZapReceiptFilters(userPubkey: string) {
|
||||||
return [
|
return [
|
||||||
@ -86,7 +85,8 @@ export class NotificationService {
|
|||||||
|
|
||||||
const filters = createZapReceiptFilters(userPubkey)
|
const filters = createZapReceiptFilters(userPubkey)
|
||||||
const poolWithSub = pool as SimplePoolWithSub
|
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)
|
registerZapSubscription(sub, userPubkey, onNotification)
|
||||||
|
|
||||||
|
|||||||
@ -25,34 +25,42 @@ export class PaymentService {
|
|||||||
* Create a Lightning invoice for an article payment
|
* 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
|
* First checks if author has created an invoice in the event tags, otherwise creates a new one
|
||||||
*/
|
*/
|
||||||
async createArticlePayment(request: PaymentRequest): Promise<PaymentResult> {
|
private validateArticleAmount(zapAmount: number): { valid: boolean; error?: string } {
|
||||||
try {
|
|
||||||
// Verify article amount matches expected commission structure
|
|
||||||
const expectedAmount = PLATFORM_COMMISSIONS.article.total
|
const expectedAmount = PLATFORM_COMMISSIONS.article.total
|
||||||
if (request.article.zapAmount !== expectedAmount) {
|
if (zapAmount !== expectedAmount) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
valid: false,
|
||||||
error: `Invalid article payment amount: ${request.article.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
|
error: `Invalid article payment amount: ${zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
const invoice = await resolveArticleInvoice(request.article)
|
private validateInvoiceAmount(invoice: AlbyInvoice): { valid: boolean; error?: string } {
|
||||||
|
|
||||||
// Verify invoice amount matches expected commission structure
|
|
||||||
const split = calculateArticleSplit()
|
const split = calculateArticleSplit()
|
||||||
if (invoice.amount !== split.total) {
|
if (invoice.amount !== split.total) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
valid: false,
|
||||||
error: `Invoice amount mismatch: ${invoice.amount} sats. Expected ${split.total} sats (${split.author} to author, ${split.platform} commission)`,
|
error: `Invoice amount mismatch: ${invoice.amount} sats. Expected ${split.total} sats (${split.author} to author, ${split.platform} commission)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
// Create zap request event on Nostr
|
async createArticlePayment(request: PaymentRequest): Promise<PaymentResult> {
|
||||||
await nostrService.createZapRequest(
|
try {
|
||||||
request.article.pubkey,
|
const amountValidation = this.validateArticleAmount(request.article.zapAmount)
|
||||||
request.article.id,
|
if (!amountValidation.valid) {
|
||||||
request.article.zapAmount
|
return { success: false, error: amountValidation.error ?? 'Invalid amount' }
|
||||||
)
|
}
|
||||||
|
|
||||||
|
const invoice = await resolveArticleInvoice(request.article)
|
||||||
|
const invoiceValidation = this.validateInvoiceAmount(invoice)
|
||||||
|
if (!invoiceValidation.valid) {
|
||||||
|
return { success: false, error: invoiceValidation.error ?? 'Invalid invoice amount' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await nostrService.createZapRequest(request.article.pubkey, request.article.id, request.article.zapAmount)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@ -1,265 +1 @@
|
|||||||
import { nostrService } from './nostr'
|
export { waitForArticlePayment } from './paymentPollingCore'
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
57
lib/paymentPollingCore.ts
Normal file
57
lib/paymentPollingCore.ts
Normal 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
48
lib/paymentPollingMain.ts
Normal 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)
|
||||||
|
}
|
||||||
128
lib/paymentPollingTracking.ts
Normal file
128
lib/paymentPollingTracking.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/paymentPollingValidation.ts
Normal file
43
lib/paymentPollingValidation.ts
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/paymentPollingZapReceipt.ts
Normal file
83
lib/paymentPollingZapReceipt.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,8 +49,10 @@ export const PLATFORM_COMMISSIONS = {
|
|||||||
/**
|
/**
|
||||||
* Platform Lightning address/node for receiving commissions
|
* Platform Lightning address/node for receiving commissions
|
||||||
* This should be configured with the platform's Lightning node
|
* 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
|
* Calculate commission split for article payment
|
||||||
|
|||||||
@ -1,9 +1,29 @@
|
|||||||
export const PLATFORM_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu'
|
export const PLATFORM_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu'
|
||||||
export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y'
|
export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y'
|
||||||
|
|
||||||
|
import { getPlatformLightningAddress as getAddress, getPlatformLightningAddressSync as getAddressSync } from './config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platform Lightning address for receiving commissions
|
* Platform Lightning address for receiving commissions
|
||||||
* This should be configured with the platform's Lightning node
|
* This should be configured with the platform's Lightning node
|
||||||
* Format: user@domain.com or LNURL
|
* 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()
|
||||||
|
}
|
||||||
|
|||||||
@ -1,22 +1,12 @@
|
|||||||
import { Event, EventTemplate, getEventHash, signEvent } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { nostrService } from './nostr'
|
import { nostrService } from './nostr'
|
||||||
import { PLATFORM_NPUB } from './platformConfig'
|
import { PLATFORM_NPUB } from './platformConfig'
|
||||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
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 type { ContentDeliveryTracking } from './platformTrackingTypes'
|
||||||
|
|
||||||
export interface ContentDeliveryTracking {
|
|
||||||
articleId: string
|
|
||||||
articlePubkey: string
|
|
||||||
recipientPubkey: string
|
|
||||||
messageEventId: string
|
|
||||||
zapReceiptId?: string
|
|
||||||
amount: number
|
|
||||||
authorAmount?: number
|
|
||||||
platformCommission?: number
|
|
||||||
timestamp: number
|
|
||||||
verified: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platform tracking service
|
* Platform tracking service
|
||||||
@ -25,18 +15,20 @@ export interface ContentDeliveryTracking {
|
|||||||
*/
|
*/
|
||||||
export class PlatformTrackingService {
|
export class PlatformTrackingService {
|
||||||
private readonly platformPubkey: string = PLATFORM_NPUB
|
private readonly platformPubkey: string = PLATFORM_NPUB
|
||||||
private readonly trackingKind = 30078 // Custom kind for platform tracking
|
|
||||||
|
|
||||||
/**
|
private async publishTrackingEvent(event: Event): Promise<void> {
|
||||||
* Publish a content delivery tracking event
|
const pool = nostrService.getPool()
|
||||||
* This event is published by the author but tagged for platform tracking
|
if (!pool) {
|
||||||
* The platform can query these events to track all content deliveries
|
throw new Error('Pool not initialized')
|
||||||
*/
|
}
|
||||||
async trackContentDelivery(
|
const poolWithSub = pool as SimplePoolWithSub
|
||||||
tracking: ContentDeliveryTracking,
|
const { getPrimaryRelaySync } = await import('./config')
|
||||||
authorPrivateKey: string
|
const relayUrl = getPrimaryRelaySync()
|
||||||
): Promise<string | null> {
|
const pubs = poolWithSub.publish([relayUrl], event)
|
||||||
try {
|
await Promise.all(pubs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateTrackingPool(): { pool: SimplePoolWithSub; authorPubkey: string } | null {
|
||||||
const pool = nostrService.getPool()
|
const pool = nostrService.getPool()
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
console.error('Pool not initialized for platform tracking')
|
console.error('Pool not initialized for platform tracking')
|
||||||
@ -49,50 +41,27 @@ export class PlatformTrackingService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
return { pool: pool as SimplePoolWithSub, authorPubkey }
|
||||||
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,
|
* Publish a content delivery tracking event
|
||||||
...eventTemplate,
|
* This event is published by the author but tagged for platform tracking
|
||||||
|
* The platform can query these events to track all content deliveries
|
||||||
|
*/
|
||||||
|
async trackContentDelivery(
|
||||||
|
tracking: ContentDeliveryTracking,
|
||||||
|
authorPrivateKey: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const validation = this.validateTrackingPool()
|
||||||
|
if (!validation) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const event: Event = {
|
const { authorPubkey } = validation
|
||||||
...unsignedEvent,
|
const event = buildTrackingEvent(tracking, authorPubkey, authorPrivateKey, this.platformPubkey)
|
||||||
id: getEventHash(unsignedEvent),
|
await this.publishTrackingEvent(event)
|
||||||
sig: signEvent(unsignedEvent, authorPrivateKey),
|
|
||||||
} as Event
|
|
||||||
|
|
||||||
const poolWithSub = pool as SimplePoolWithSub
|
|
||||||
const pubs = poolWithSub.publish([RELAY_URL], event)
|
|
||||||
await Promise.all(pubs)
|
|
||||||
|
|
||||||
console.log('Platform tracking event published', {
|
console.log('Platform tracking event published', {
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
@ -127,20 +96,11 @@ export class PlatformTrackingService {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = [
|
|
||||||
{
|
|
||||||
kinds: [this.trackingKind],
|
|
||||||
'#p': [this.platformPubkey],
|
|
||||||
'#article': [articleId],
|
|
||||||
limit: 100,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const deliveries: ContentDeliveryTracking[] = []
|
const deliveries: ContentDeliveryTracking[] = []
|
||||||
let resolved = false
|
let resolved = false
|
||||||
const poolWithSub = pool as SimplePoolWithSub
|
const poolWithSub = pool as SimplePoolWithSub
|
||||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
const sub = createArticleDeliveriesSubscription(poolWithSub, articleId, this.platformPubkey)
|
||||||
|
|
||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
@ -152,34 +112,9 @@ export class PlatformTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sub.on('event', (event: Event) => {
|
sub.on('event', (event: Event) => {
|
||||||
try {
|
const delivery = parseTrackingEvent(event)
|
||||||
const data = JSON.parse(event.content) as ContentDeliveryTracking
|
if (delivery) {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
deliveries.push(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 []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = [
|
|
||||||
{
|
|
||||||
kinds: [this.trackingKind],
|
|
||||||
'#p': [this.platformPubkey],
|
|
||||||
'#recipient': [recipientPubkey],
|
|
||||||
limit: 100,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const deliveries: ContentDeliveryTracking[] = []
|
const deliveries: ContentDeliveryTracking[] = []
|
||||||
let resolved = false
|
let resolved = false
|
||||||
const poolWithSub = pool as SimplePoolWithSub
|
const poolWithSub = pool as SimplePoolWithSub
|
||||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
const sub = createRecipientDeliveriesSubscription(poolWithSub, recipientPubkey, this.platformPubkey)
|
||||||
|
|
||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
@ -230,24 +156,9 @@ export class PlatformTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sub.on('event', (event: Event) => {
|
sub.on('event', (event: Event) => {
|
||||||
try {
|
const delivery = parseTrackingEvent(event)
|
||||||
const data = JSON.parse(event.content) as ContentDeliveryTracking
|
if (delivery) {
|
||||||
const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
|
|
||||||
|
|
||||||
const delivery: ContentDeliveryTracking = {
|
|
||||||
...data,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zapReceiptTag) {
|
|
||||||
delivery.zapReceiptId = zapReceiptTag
|
|
||||||
}
|
|
||||||
|
|
||||||
deliveries.push(delivery)
|
deliveries.push(delivery)
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing tracking event', {
|
|
||||||
eventId: event.id,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
60
lib/platformTrackingEvents.ts
Normal file
60
lib/platformTrackingEvents.ts
Normal 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
|
||||||
|
}
|
||||||
64
lib/platformTrackingQueries.ts
Normal file
64
lib/platformTrackingQueries.ts
Normal 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)
|
||||||
|
}
|
||||||
12
lib/platformTrackingTypes.ts
Normal file
12
lib/platformTrackingTypes.ts
Normal 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
|
||||||
|
}
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { getAlbyService } from './alby'
|
import { calculateReviewSplit } from './platformCommissions'
|
||||||
import { calculateReviewSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
|
import type { ReviewRewardRequest, ReviewRewardResult } from './reviewRewardTypes'
|
||||||
import { automaticTransferService } from './automaticTransfer'
|
import { createReviewInvoice } from './reviewRewardInvoice'
|
||||||
import { nostrService } from './nostr'
|
import { transferReviewerPortionIfAvailable } from './reviewRewardTransfer'
|
||||||
import { lightningAddressService } from './lightningAddress'
|
import { trackReviewReward } from './reviewRewardTracking'
|
||||||
import type { AlbyInvoice } from '@/types/alby'
|
import { updateReviewWithReward } from './reviewRewardUpdate'
|
||||||
import type { Event } from 'nostr-tools'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Review reward service
|
* Review reward service
|
||||||
@ -14,46 +13,13 @@ import type { Event } from 'nostr-tools'
|
|||||||
* - Reviewer: 49 sats
|
* - Reviewer: 49 sats
|
||||||
* - Platform: 21 sats
|
* - Platform: 21 sats
|
||||||
*/
|
*/
|
||||||
export interface ReviewRewardRequest {
|
export type { ReviewRewardRequest, ReviewRewardResult } from './reviewRewardTypes'
|
||||||
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 class ReviewRewardService {
|
export class ReviewRewardService {
|
||||||
/**
|
|
||||||
* Create review reward payment with commission split
|
|
||||||
*/
|
|
||||||
async createReviewRewardPayment(request: ReviewRewardRequest): Promise<ReviewRewardResult> {
|
async createReviewRewardPayment(request: ReviewRewardRequest): Promise<ReviewRewardResult> {
|
||||||
try {
|
try {
|
||||||
const split = calculateReviewSplit()
|
const split = calculateReviewSplit()
|
||||||
|
const invoice = await createReviewInvoice(split, request)
|
||||||
// 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
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('Review reward invoice created', {
|
console.log('Review reward invoice created', {
|
||||||
reviewId: request.reviewId,
|
reviewId: request.reviewId,
|
||||||
@ -97,44 +63,9 @@ export class ReviewRewardService {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const split = calculateReviewSplit()
|
const split = calculateReviewSplit()
|
||||||
|
await transferReviewerPortionIfAvailable(request, split)
|
||||||
// Get reviewer Lightning address if not provided
|
await trackReviewReward(request, split, paymentHash)
|
||||||
let reviewerLightningAddress: string | undefined = request.reviewerLightningAddress
|
await updateReviewWithReward(request.reviewId, request.authorPrivateKey)
|
||||||
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)
|
|
||||||
|
|
||||||
console.log('Review reward processed', {
|
console.log('Review reward processed', {
|
||||||
reviewId: request.reviewId,
|
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()
|
export const reviewRewardService = new ReviewRewardService()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user