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
|
||||
* clavier
|
||||
* contraste
|
||||
|
||||
## Open Source
|
||||
|
||||
Le projet est open source et hébergé sur Gitea auto-hébergé. Toutes les contributions doivent respecter les principes open source suivants.
|
||||
|
||||
### Principes open source
|
||||
|
||||
* **Transparence** : Tous les changements doivent être traçables, documentés et accessibles publiquement
|
||||
* **Collaboration** : Le code doit être conçu pour faciliter les contributions externes
|
||||
* **Documentation** : Toute fonctionnalité ou modification doit être documentée pour les contributeurs externes
|
||||
* **Standards ouverts** : Utiliser des standards ouverts et éviter les dépendances propriétaires non documentées
|
||||
* **Licence MIT** : Le code doit respecter la licence MIT et être compatible avec cette licence
|
||||
|
||||
### Contribution et workflow open source
|
||||
|
||||
* **Commits systématiques** : Tous les changements doivent être commités avec des messages structurés
|
||||
* **Format de commit** : Les commits doivent suivre le format défini dans CONTRIBUTING.md avec sections Motivations, Root causes, Correctifs, Evolutions, Pages affectées
|
||||
* **Pull Requests** : Tous les changements doivent passer par des Pull Requests sur Gitea, même pour les modifications internes
|
||||
* **Documentation des changements** : Toute modification doit être documentée dans `fixKnowledge/` (pour les corrections) ou `features/` (pour les évolutions)
|
||||
* **Traçabilité** : Chaque modification doit être liée à une issue ou une PR sur Gitea si applicable
|
||||
|
||||
### Qualité pour contributions externes
|
||||
|
||||
* **Code lisible** : Le code doit être compréhensible par des contributeurs externes sans connaissance préalable du contexte interne
|
||||
* **Commentaires explicatifs** : Les choix de conception non évidents doivent être commentés
|
||||
* **Documentation à jour** : La documentation doit toujours refléter l'état actuel du code
|
||||
* **Exemples et guides** : Fournir des exemples d'utilisation et des guides pour les nouvelles fonctionnalités
|
||||
* **Tests et validation** : Le code doit être validé et testable par des contributeurs externes
|
||||
|
||||
### Gestion des contributions
|
||||
|
||||
* **Respect du Code of Conduct** : Tous les contributeurs doivent respecter le CODE_OF_CONDUCT.md
|
||||
* **Review process** : Toutes les contributions doivent être revues avant merge
|
||||
* **Feedback constructif** : Fournir un feedback constructif et respectueux sur les contributions
|
||||
* **Attribution** : Créditer les contributeurs dans les commits et la documentation
|
||||
|
||||
### Sécurité open source
|
||||
|
||||
* **Reporting de vulnérabilités** : Suivre le processus défini dans SECURITY.md
|
||||
* **Pas de secrets** : Aucun secret, clé API ou credential ne doit être commité
|
||||
* **Dépendances sécurisées** : Vérifier la sécurité des dépendances avant ajout
|
||||
* **Audit de sécurité** : Les changements de sécurité doivent être documentés et audités
|
||||
|
||||
### Repository et infrastructure
|
||||
|
||||
* **Repository Gitea** : https://git.4nkweb.com/4nk/story-research-zapwall
|
||||
* **Templates** : Utiliser les templates d'issues et de PR dans `.gitea/`
|
||||
* **Labels et organisation** : Utiliser les labels appropriés pour organiser les issues et PRs
|
||||
* **Branches** : Respecter la convention de nommage des branches (feature/, fix/, etc.)
|
||||
|
||||
## Commits systématiques
|
||||
|
||||
**Règle absolue** : Tous les changements doivent être commités immédiatement après leur réalisation.
|
||||
|
||||
### Obligation de commit
|
||||
|
||||
* **Pas de modifications non commitées** : Aucune modification ne doit rester non commitée
|
||||
* **Commits atomiques** : Chaque commit doit représenter une modification logique et complète
|
||||
* **Commits fréquents** : Commiter régulièrement, pas seulement à la fin d'une session
|
||||
* **Pas de stash prolongé** : Éviter de laisser des modifications en stash sans les commiter
|
||||
|
||||
### Format de commit obligatoire
|
||||
|
||||
Tous les commits doivent suivre ce format structuré :
|
||||
|
||||
```
|
||||
Titre court et descriptif
|
||||
|
||||
**Motivations:**
|
||||
- Raison de la modification
|
||||
|
||||
**Root causes:**
|
||||
- Cause racine du problème (si applicable)
|
||||
|
||||
**Correctifs:**
|
||||
- Ce qui a été corrigé
|
||||
|
||||
**Evolutions:**
|
||||
- Nouvelles fonctionnalités ou améliorations
|
||||
|
||||
**Pages affectées:**
|
||||
- Liste des fichiers/modules modifiés
|
||||
```
|
||||
|
||||
### Processus de commit
|
||||
|
||||
1. **Avant chaque commit** :
|
||||
- Vérifier que le code compile (`npm run type-check`)
|
||||
- Vérifier le linting (`npm run lint`)
|
||||
- Vérifier que les modifications sont complètes et fonctionnelles
|
||||
|
||||
2. **Création du commit** :
|
||||
- Utiliser `git add` pour les fichiers modifiés
|
||||
- Créer un commit avec le format structuré
|
||||
- Ne pas utiliser `--no-verify` sauf cas exceptionnel documenté
|
||||
|
||||
3. **Après le commit** :
|
||||
- Vérifier que le commit a bien été créé (`git log`)
|
||||
- Documenter dans `fixKnowledge/` ou `features/` si nécessaire
|
||||
|
||||
### Exceptions
|
||||
|
||||
Les seules exceptions à la règle de commit immédiat sont :
|
||||
|
||||
* **Modifications en cours de test** : Si une modification nécessite des tests manuels avant commit, elle doit être commitée dès que les tests sont validés
|
||||
* **Refactoring en plusieurs étapes** : Les refactorings complexes peuvent être commités par étapes logiques
|
||||
* **Documentation en cours** : La documentation peut être commitée séparément du code si elle est volumineuse
|
||||
|
||||
Dans tous les cas, aucun changement ne doit rester non commité plus de quelques heures.
|
||||
|
||||
### Intégration avec le workflow open source
|
||||
|
||||
* **Commits avant PR** : Tous les commits doivent être faits avant la création d'une Pull Request
|
||||
* **Commits dans les PRs** : Les commits dans une PR doivent être organisés et logiques
|
||||
* **Squash si nécessaire** : Les commits peuvent être squashés dans une PR si cela améliore la lisibilité, mais chaque commit individuel doit rester valide
|
||||
* **Historique propre** : Maintenir un historique Git propre et lisible pour les contributeurs externes
|
||||
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
|
||||
- No fallbacks or silent failures.
|
||||
- No analytics; no tests added unless explicitly requested.
|
||||
- Respect lint, type-check, accessibility and exactOptionalPropertyTypes.
|
||||
- No `ts-ignore`, no untyped `any`, no console logs if a logger exists.
|
||||
Thank you for your interest in contributing to zapwall4Science! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## Setup
|
||||
- Node 18+, npm
|
||||
- `npm install`
|
||||
- `npm run lint`
|
||||
- `npm run type-check`
|
||||
## Table of Contents
|
||||
|
||||
## Coding guidelines
|
||||
- Split large components/functions to stay within lint limits (max-lines, max-lines-per-function).
|
||||
- Prefer typed helpers/hooks; avoid duplication.
|
||||
- Errors must surface with clear messages; do not swallow exceptions.
|
||||
- Storage: IndexedDB encrypted (AES-GCM) via `lib/storage/cryptoHelpers.ts`; use provided helpers.
|
||||
- Nostr: use `lib/articleMutations.ts` and `lib/nostr*.ts` helpers; no direct fallbacks.
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Workflow](#workflow)
|
||||
- [Commit Guidelines](#commit-guidelines)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Documentation](#documentation)
|
||||
- [What Not to Do](#what-not-to-do)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). We are committed to providing a welcoming and inclusive environment for all contributors.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork the repository** on Gitea
|
||||
2. **Clone your fork** locally:
|
||||
```bash
|
||||
git clone https://git.4nkweb.com/your-username/story-research-zapwall.git
|
||||
cd story-research-zapwall
|
||||
```
|
||||
3. **Add the upstream remote**:
|
||||
```bash
|
||||
git remote add upstream https://git.4nkweb.com/4nk/story-research-zapwall.git
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: 18 or higher
|
||||
- **npm**: Latest version
|
||||
- **Alby browser extension**: For testing Nostr authentication and Lightning payments
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Run linting:
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
3. Run type checking:
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open [http://localhost:3000](http://localhost:3000) in your browser
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### Core Principles
|
||||
|
||||
- **No fallbacks or silent failures**: All errors must be explicitly handled and logged
|
||||
- **No analytics**: Analytics are not allowed in this project
|
||||
- **No tests unless explicitly requested**: Do not add ad-hoc tests
|
||||
- **Strict TypeScript**: Respect lint, type-check, accessibility, and `exactOptionalPropertyTypes`
|
||||
- **No shortcuts**: No `ts-ignore`, no untyped `any`, no `console.log` if a logger exists
|
||||
|
||||
### Code Quality
|
||||
|
||||
- **Split large components/functions**: Stay within lint limits (max-lines, max-lines-per-function)
|
||||
- **Prefer typed helpers/hooks**: Avoid duplication, reuse existing utilities
|
||||
- **Error handling**: Errors must surface with clear messages; do not swallow exceptions
|
||||
- **Storage**: Use IndexedDB encrypted (AES-GCM) via `lib/storage/cryptoHelpers.ts`
|
||||
- **Nostr**: Use `lib/articleMutations.ts` and `lib/nostr*.ts` helpers; no direct fallbacks
|
||||
|
||||
### TypeScript Standards
|
||||
|
||||
- Full type coverage: No `any` types without explicit justification
|
||||
- No type assertions: Avoid `as` casts unless absolutely necessary
|
||||
- Strict mode: All TypeScript strict checks must pass
|
||||
- No `@ts-ignore` or `@ts-nocheck`: Fix type issues properly
|
||||
|
||||
### Accessibility
|
||||
|
||||
- **ARIA**: Proper ARIA labels and roles
|
||||
- **Keyboard navigation**: All interactive elements must be keyboard accessible
|
||||
- **Contrast**: Meet WCAG contrast requirements
|
||||
- **No regressions**: Maintain or improve accessibility with each change
|
||||
|
||||
## Workflow
|
||||
- Branch from main; keep commits focused.
|
||||
- Run lint + type-check before PR.
|
||||
- Document fixes in `fixKnowledge/` and features in `features/`.
|
||||
|
||||
## Accessibility
|
||||
- Respect ARIA, keyboard, contrast requirements; no regressions.
|
||||
### Creating a Branch
|
||||
|
||||
## What not to do
|
||||
- No analytics, no ad-hoc tests, no environment overrides, no silent retry/fallback.
|
||||
1. **Update your fork**:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull upstream main
|
||||
```
|
||||
|
||||
2. **Create a feature branch**:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix
|
||||
```
|
||||
|
||||
### Making Changes
|
||||
|
||||
1. Make your changes following the [coding guidelines](#coding-guidelines)
|
||||
2. Test your changes locally
|
||||
3. Run lint and type-check:
|
||||
```bash
|
||||
npm run lint
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
### Before Submitting
|
||||
|
||||
- [ ] Code follows the project's coding guidelines
|
||||
- [ ] Lint passes (`npm run lint`)
|
||||
- [ ] Type-check passes (`npm run type-check`)
|
||||
- [ ] No `console.log` statements (use logger if available)
|
||||
- [ ] Errors are properly handled and logged
|
||||
- [ ] Accessibility requirements are met
|
||||
- [ ] Documentation is updated if needed
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
Commits should be exhaustive and synthetic with the following structure:
|
||||
|
||||
```
|
||||
**Motivations:**
|
||||
- Reason for the change
|
||||
|
||||
**Root causes:**
|
||||
- Underlying issue (if applicable)
|
||||
|
||||
**Correctifs:**
|
||||
- What was fixed
|
||||
|
||||
**Evolutions:**
|
||||
- New features or improvements
|
||||
|
||||
**Pages affectées:**
|
||||
- Files or components modified
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
Fix payment modal not closing after successful payment
|
||||
|
||||
**Motivations:**
|
||||
- Users reported payment modal staying open after successful payment
|
||||
|
||||
**Root causes:**
|
||||
- Missing state update after payment confirmation
|
||||
|
||||
**Correctifs:**
|
||||
- Added state reset in payment success handler
|
||||
- Added cleanup in useEffect hook
|
||||
|
||||
**Evolutions:**
|
||||
- Improved user experience with automatic modal closure
|
||||
|
||||
**Pages affectées:**
|
||||
- components/PaymentModal.tsx
|
||||
- hooks/useArticlePayment.ts
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Update your branch**:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull upstream main
|
||||
git checkout your-branch-name
|
||||
git rebase main
|
||||
```
|
||||
|
||||
2. **Push your changes**:
|
||||
```bash
|
||||
git push origin your-branch-name
|
||||
```
|
||||
|
||||
3. **Create a Pull Request** on Gitea:
|
||||
- Use a clear, descriptive title
|
||||
- Fill out the PR template completely
|
||||
- Reference any related issues
|
||||
- Add screenshots if UI changes are involved
|
||||
|
||||
4. **Wait for review**: Maintainers will review your PR and provide feedback
|
||||
|
||||
5. **Address feedback**: Make requested changes and push updates to your branch
|
||||
|
||||
6. **Documentation**:
|
||||
- Document fixes in `fixKnowledge/` directory
|
||||
- Document features in `features/` directory
|
||||
|
||||
## Documentation
|
||||
|
||||
### When to Document
|
||||
|
||||
- **Fixes**: Document in `fixKnowledge/` with problem, impacts, root cause, corrections, modifications, deployment, and analysis procedures
|
||||
- **Features**: Document in `features/` with objective, impacts, modifications, deployment, and analysis procedures
|
||||
|
||||
### Documentation Format
|
||||
|
||||
Follow the existing documentation structure:
|
||||
- Clear sections with headers
|
||||
- Code examples where relevant
|
||||
- Links to related documentation
|
||||
- Author attribution (Équipe 4NK)
|
||||
|
||||
## What Not to Do
|
||||
|
||||
- ❌ **No analytics**: Do not add analytics tracking
|
||||
- ❌ **No ad-hoc tests**: Do not add tests unless explicitly requested
|
||||
- ❌ **No environment overrides**: Do not override environment variables
|
||||
- ❌ **No silent retry/fallback**: All failures must be explicit
|
||||
- ❌ **No shortcuts**: No `ts-ignore`, no `any`, no `console.log`
|
||||
- ❌ **No breaking changes**: Maintain backward compatibility unless explicitly required
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Documentation**: Check the [docs/](docs/) directory
|
||||
- **Issues**: Search existing issues or create a new one on [Gitea](https://git.4nkweb.com/4nk/story-research-zapwall/issues)
|
||||
- **Repository**: [https://git.4nkweb.com/4nk/story-research-zapwall](https://git.4nkweb.com/4nk/story-research-zapwall)
|
||||
|
||||
Thank you for contributing to zapwall4Science! 🚀
|
||||
|
||||
21
LICENSE
Normal file
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
|
||||
|
||||
[](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.
|
||||
|
||||
**Repository**: [https://git.4nkweb.com/4nk/story-research-zapwall](https://git.4nkweb.com/4nk/story-research-zapwall)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Configuration](#configuration)
|
||||
- [Lightning Wallet Setup](#lightning-wallet-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Contributing](#contributing)
|
||||
- [Documentation](#documentation)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
|
||||
- **Nostr Authentication**: Authenticate using Alby browser extension (NIP-07)
|
||||
@ -25,23 +42,24 @@ npm run dev
|
||||
|
||||
3. Open [http://localhost:3000](http://localhost:3000) in your browser
|
||||
|
||||
## Environment Variables
|
||||
## Configuration
|
||||
|
||||
- `NEXT_PUBLIC_NOSTR_RELAY_URL`: Nostr relay URL (default: wss://relay.damus.io)
|
||||
- `NEXT_PUBLIC_NIP95_UPLOAD_URL`: NIP-95 media upload endpoint URL (required for image/video uploads)
|
||||
The application stores all configuration in IndexedDB (browser storage) with hardcoded defaults. No environment variables are required.
|
||||
|
||||
### NIP-95 Upload Service
|
||||
### Default Configuration
|
||||
|
||||
The application requires a NIP-95 compatible upload service for media uploads (images and videos). You can use services like:
|
||||
- [nostr.build](https://nostr.build/) - Public NIP-95 service
|
||||
- [void.cat](https://void.cat/) - Another public NIP-95 service
|
||||
- Or host your own NIP-95 compatible service
|
||||
- **Nostr Relay**: `wss://relay.damus.io` (default)
|
||||
- **NIP-95 Upload API**: `https://nostr.build/api/v2/upload` (default)
|
||||
- **Platform Lightning Address**: Empty by default
|
||||
|
||||
Example `.env.local`:
|
||||
```
|
||||
NEXT_PUBLIC_NOSTR_RELAY_URL=wss://relay.damus.io
|
||||
NEXT_PUBLIC_NIP95_UPLOAD_URL=https://nostr.build/api/v2/upload
|
||||
```
|
||||
### Customizing Configuration
|
||||
|
||||
Configuration is stored in IndexedDB and can be customized through the application settings. The application supports:
|
||||
- Multiple Nostr relays (with priority ordering)
|
||||
- Multiple NIP-95 upload APIs (with priority ordering)
|
||||
- Platform Lightning address for commissions
|
||||
|
||||
All configuration values are stored locally in the browser and persist across sessions. Default values are hardcoded in the application code.
|
||||
|
||||
## Lightning Wallet Setup
|
||||
|
||||
@ -58,3 +76,75 @@ Users need to have Alby installed to authenticate and make payments. The applica
|
||||
- `/lib`: Utilities and Nostr helpers
|
||||
- `/types`: TypeScript type definitions
|
||||
- `/hooks`: Custom React hooks
|
||||
|
||||
## Déploiement
|
||||
|
||||
### Documentation complète
|
||||
|
||||
La documentation complète du déploiement est disponible dans le dossier `docs/` :
|
||||
|
||||
- **[Documentation complète du déploiement](docs/deployment.md)** : Guide détaillé de déploiement, configuration et maintenance
|
||||
- **[Référence des scripts](docs/scripts-reference.md)** : Description de tous les scripts disponibles
|
||||
- **[Guide de référence rapide](docs/quick-reference.md)** : Commandes essentielles
|
||||
|
||||
### Déploiement rapide
|
||||
|
||||
Le site est déployé sur `zapwall.fr` (serveur : `92.243.27.35`).
|
||||
|
||||
**Mise à jour du site** :
|
||||
|
||||
```bash
|
||||
# Méthode recommandée : Script automatique
|
||||
./update-remote-git.sh
|
||||
```
|
||||
|
||||
**Vérification du statut** :
|
||||
|
||||
```bash
|
||||
ssh debian@92.243.27.35 'sudo systemctl status zapwall'
|
||||
```
|
||||
|
||||
### Informations de déploiement
|
||||
|
||||
- **Répertoire** : `/var/www/zapwall.fr`
|
||||
- **Port application** : `3001`
|
||||
- **Service systemd** : `zapwall.service`
|
||||
- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
|
||||
- **HTTPS** : Configuré avec redirection automatique HTTP → HTTPS
|
||||
|
||||
Pour plus de détails, consultez la [documentation complète](docs/deployment.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please read our [Contributing Guide](CONTRIBUTING.md) to get started.
|
||||
|
||||
### How to Contribute
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes following our [coding guidelines](CONTRIBUTING.md#coding-guidelines)
|
||||
4. Run lint and type-check (`npm run lint && npm run type-check`)
|
||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
Please ensure your code follows our strict quality standards:
|
||||
- No fallbacks or silent failures
|
||||
- Full TypeScript typing (no `any`, no `ts-ignore`)
|
||||
- Proper error handling and logging
|
||||
- Accessibility compliance (ARIA, keyboard navigation, contrast)
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[User Guide](docs/user-guide.md)**: Complete user documentation
|
||||
- **[Technical Documentation](docs/technical.md)**: Architecture and technical details
|
||||
- **[Deployment Guide](docs/deployment.md)**: Deployment and configuration
|
||||
- **[FAQ](docs/faq.md)**: Frequently asked questions
|
||||
- **[Publishing Guide](docs/publishing-guide.md)**: How to publish articles
|
||||
- **[Payment Guide](docs/payment-guide.md)**: Lightning payment setup
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
143
RESUME-DEPLOIEMENT.md
Normal file
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
|
||||
}
|
||||
|
||||
function ArticleHeader({ article }: { article: Article }) {
|
||||
return (
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
|
||||
<Link
|
||||
href={`/author/${article.pubkey}`}
|
||||
className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors"
|
||||
>
|
||||
{t('publication.viewAuthor')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticleMeta({
|
||||
article,
|
||||
error,
|
||||
@ -56,15 +70,7 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps) {
|
||||
|
||||
return (
|
||||
<article className="border border-neon-cyan/30 rounded-lg p-6 bg-cyber-dark hover:border-neon-cyan/50 hover:shadow-glow-cyan transition-all">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-neon-cyan">{article.title}</h2>
|
||||
<Link
|
||||
href={`/author/${article.pubkey}`}
|
||||
className="text-xs text-cyber-accent/70 hover:text-neon-cyan transition-colors"
|
||||
>
|
||||
{t('publication.viewAuthor')}
|
||||
</Link>
|
||||
</div>
|
||||
<ArticleHeader article={article} />
|
||||
<div className="text-cyber-accent mb-4">
|
||||
<ArticlePreview
|
||||
article={article}
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Image from 'next/image'
|
||||
import React from 'react'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { useAuthorsProfiles } from '@/hooks/useAuthorsProfiles'
|
||||
import { generateMnemonicIcons } from '@/lib/mnemonicIcons'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { AuthorFilter } from './AuthorFilter'
|
||||
|
||||
export type SortOption = 'newest' | 'oldest'
|
||||
|
||||
@ -78,191 +76,6 @@ function FiltersHeader({
|
||||
)
|
||||
}
|
||||
|
||||
function AuthorFilter({
|
||||
authors,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
authors: string[]
|
||||
value: string | null
|
||||
onChange: (value: string | null) => void
|
||||
}) {
|
||||
const { profiles, loading } = useAuthorsProfiles(authors)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
buttonRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const getDisplayName = (pubkey: string): string => {
|
||||
const profile = profiles.get(pubkey)
|
||||
return profile?.name ?? `${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}`
|
||||
}
|
||||
|
||||
const getPicture = (pubkey: string): string | undefined => {
|
||||
return profiles.get(pubkey)?.picture
|
||||
}
|
||||
|
||||
const getMnemonicIcons = (pubkey: string): string[] => {
|
||||
return generateMnemonicIcons(pubkey)
|
||||
}
|
||||
|
||||
const selectedAuthor = value ? profiles.get(value) : null
|
||||
const selectedDisplayName = value ? getDisplayName(value) : t('filters.author')
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label htmlFor="author-filter" className="block text-sm font-medium text-cyber-accent mb-1">
|
||||
{t('filters.author')}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
id="author-filter"
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent text-left flex items-center gap-2 hover:border-neon-cyan/50 transition-colors"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{value && selectedAuthor?.picture ? (
|
||||
<Image
|
||||
src={selectedAuthor.picture}
|
||||
alt={selectedDisplayName}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full object-cover border border-neon-cyan/30"
|
||||
/>
|
||||
) : value ? (
|
||||
<div className="w-6 h-6 rounded-full bg-cyber-light border border-neon-cyan/30 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs text-neon-cyan font-medium">
|
||||
{selectedDisplayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<span className="flex-1 truncate text-cyber-accent">{selectedDisplayName}</span>
|
||||
{value && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{getMnemonicIcons(value).map((icon, idx) => (
|
||||
<span key={idx} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
|
||||
{icon}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<svg
|
||||
className={`w-5 h-5 text-neon-cyan transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute z-20 w-full mt-1 bg-cyber-dark border border-neon-cyan/30 rounded-lg shadow-glow-cyan max-h-60 overflow-auto"
|
||||
role="listbox"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(null)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
|
||||
value === null ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={value === null}
|
||||
>
|
||||
<span className="flex-1 text-cyber-accent">{t('filters.author')}</span>
|
||||
</button>
|
||||
{loading ? (
|
||||
<div className="px-3 py-2 text-sm text-cyber-accent/70">{t('filters.loading')}</div>
|
||||
) : (
|
||||
authors.map((pubkey) => {
|
||||
const displayName = getDisplayName(pubkey)
|
||||
const picture = getPicture(pubkey)
|
||||
const mnemonicIcons = getMnemonicIcons(pubkey)
|
||||
const isSelected = value === pubkey
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pubkey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(pubkey)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
|
||||
isSelected ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
{picture ? (
|
||||
<Image
|
||||
src={picture}
|
||||
alt={displayName}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full object-cover flex-shrink-0 border border-neon-cyan/30"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-cyber-light border border-neon-cyan/30 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs text-neon-cyan font-medium">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-1 truncate text-cyber-accent">{displayName}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{mnemonicIcons.map((icon, idx) => (
|
||||
<span key={idx} className="text-sm" title={`Mnemonic icon ${idx + 1}`}>
|
||||
{icon}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortFilter({
|
||||
value,
|
||||
@ -279,7 +92,7 @@ function SortFilter({
|
||||
<select
|
||||
id="sort"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as SortOption)}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onChange(e.target.value as SortOption)}
|
||||
className="block w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent hover:border-neon-cyan/50 transition-colors"
|
||||
>
|
||||
<option value="newest" className="bg-cyber-dark">{t('filters.sort.newest')}</option>
|
||||
|
||||
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 { ArticleField } from './ArticleField'
|
||||
import { ArticleFormButtons } from './ArticleFormButtons'
|
||||
import { ConnectButton } from './ConnectButton'
|
||||
import { CreateAccountModal } from './CreateAccountModal'
|
||||
import { ImageUploadField } from './ImageUploadField'
|
||||
import { PresentationFormHeader } from './PresentationFormHeader'
|
||||
import { t } from '@/lib/i18n'
|
||||
@ -193,6 +193,32 @@ function useAuthorPresentationState(pubkey: string | null) {
|
||||
return { loading, error, success, draft, setDraft, validationError, handleSubmit }
|
||||
}
|
||||
|
||||
function NoAccountView() {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-cyber-accent mb-2">
|
||||
Créez un compte ou importez votre clé secrète pour commencer
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
Créer un compte ou importer une clé
|
||||
</button>
|
||||
{showCreateModal && (
|
||||
<CreateAccountModal
|
||||
onSuccess={() => setShowCreateModal(false)}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthorPresentationFormView({
|
||||
pubkey,
|
||||
connected,
|
||||
@ -205,13 +231,7 @@ function AuthorPresentationFormView({
|
||||
const state = useAuthorPresentationState(pubkey)
|
||||
|
||||
if (!pubkey) {
|
||||
return (
|
||||
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <NoAccountView />
|
||||
}
|
||||
if (state.success) {
|
||||
return <SuccessNotice />
|
||||
|
||||
@ -4,6 +4,32 @@ import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
const buttonClassName = 'px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan'
|
||||
|
||||
function CreateAuthorPageLink() {
|
||||
return (
|
||||
<Link href="/presentation" className={buttonClassName}>
|
||||
{t('nav.createAuthorPage')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function PublishLink() {
|
||||
return (
|
||||
<Link href="/publish" className={buttonClassName}>
|
||||
{t('nav.publish')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingButton() {
|
||||
return (
|
||||
<div className="px-4 py-2 bg-neon-cyan/20 text-neon-cyan rounded-lg text-sm font-medium">
|
||||
{t('nav.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConditionalPublishButton() {
|
||||
const { connected, pubkey } = useNostrAuth()
|
||||
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
|
||||
@ -22,41 +48,16 @@ export function ConditionalPublishButton() {
|
||||
}, [connected, pubkey, checkPresentationExists])
|
||||
|
||||
if (!connected || !pubkey) {
|
||||
return (
|
||||
<Link
|
||||
href="/presentation"
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{t('nav.createAuthorPage')}
|
||||
</Link>
|
||||
)
|
||||
return <CreateAuthorPageLink />
|
||||
}
|
||||
|
||||
if (hasPresentation === null) {
|
||||
return (
|
||||
<div className="px-4 py-2 bg-neon-cyan/20 text-neon-cyan rounded-lg text-sm font-medium">
|
||||
{t('nav.loading')}
|
||||
</div>
|
||||
)
|
||||
return <LoadingButton />
|
||||
}
|
||||
|
||||
if (!hasPresentation) {
|
||||
return (
|
||||
<Link
|
||||
href="/presentation"
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{t('nav.createAuthorPage')}
|
||||
</Link>
|
||||
)
|
||||
return <CreateAuthorPageLink />
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/publish"
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
>
|
||||
{t('nav.publish')}
|
||||
</Link>
|
||||
)
|
||||
return <PublishLink />
|
||||
}
|
||||
|
||||
@ -1,50 +1,172 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { ConnectedUserMenu } from './ConnectedUserMenu'
|
||||
import { CreateAccountModal } from './CreateAccountModal'
|
||||
import { UnlockAccountModal } from './UnlockAccountModal'
|
||||
import type { NostrProfile } from '@/types/nostr'
|
||||
|
||||
function ConnectForm({ onConnect, loading, error }: {
|
||||
onConnect: () => void
|
||||
function ConnectForm({
|
||||
onCreateAccount,
|
||||
onUnlock,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
onCreateAccount: () => void
|
||||
onUnlock: () => void
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
void onConnect()
|
||||
}}
|
||||
onClick={onCreateAccount}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Connecting...' : 'Connect with Nostr'}
|
||||
Créer un compte
|
||||
</button>
|
||||
<button
|
||||
onClick={onUnlock}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Se connecter
|
||||
</button>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConnectButton() {
|
||||
const { connected, pubkey, profile, loading, error, connect, disconnect } = useNostrAuth()
|
||||
function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showCreateModal: boolean, showUnlockModal: boolean, connect: () => Promise<void>) {
|
||||
useEffect(() => {
|
||||
if (accountExists === true && !pubkey && !showCreateModal && !showUnlockModal) {
|
||||
void connect()
|
||||
}
|
||||
}, [accountExists, pubkey, showCreateModal, showUnlockModal, connect])
|
||||
}
|
||||
|
||||
if (connected && pubkey) {
|
||||
return (
|
||||
<ConnectedUserMenu
|
||||
pubkey={pubkey}
|
||||
profile={profile}
|
||||
onDisconnect={() => {
|
||||
void disconnect()
|
||||
}}
|
||||
function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise<void> }) {
|
||||
return (
|
||||
<ConnectedUserMenu
|
||||
pubkey={pubkey}
|
||||
profile={profile}
|
||||
onDisconnect={() => {
|
||||
void disconnect()
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean; error: string | null; onUnlock: () => void; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<ConnectForm
|
||||
onCreateAccount={() => {}}
|
||||
onUnlock={onUnlock}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
<UnlockAccountModal onSuccess={onClose} onClose={onClose} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DisconnectedModals({
|
||||
showCreateModal,
|
||||
showUnlockModal,
|
||||
setShowCreateModal,
|
||||
setShowUnlockModal,
|
||||
}: {
|
||||
showCreateModal: boolean
|
||||
showUnlockModal: boolean
|
||||
setShowCreateModal: (show: boolean) => void
|
||||
setShowUnlockModal: (show: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{showCreateModal && (
|
||||
<CreateAccountModal
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false)
|
||||
setShowUnlockModal(true)
|
||||
}}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showUnlockModal && (
|
||||
<UnlockAccountModal
|
||||
onSuccess={() => setShowUnlockModal(false)}
|
||||
onClose={() => setShowUnlockModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DisconnectedState({
|
||||
loading,
|
||||
error,
|
||||
showCreateModal,
|
||||
showUnlockModal,
|
||||
setShowCreateModal,
|
||||
setShowUnlockModal,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
showCreateModal: boolean
|
||||
showUnlockModal: boolean
|
||||
setShowCreateModal: (show: boolean) => void
|
||||
setShowUnlockModal: (show: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ConnectForm
|
||||
onCreateAccount={() => setShowCreateModal(true)}
|
||||
onUnlock={() => setShowUnlockModal(true)}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
<DisconnectedModals
|
||||
showCreateModal={showCreateModal}
|
||||
showUnlockModal={showUnlockModal}
|
||||
setShowCreateModal={setShowCreateModal}
|
||||
setShowUnlockModal={setShowUnlockModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConnectButton() {
|
||||
const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth()
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showUnlockModal, setShowUnlockModal] = useState(false)
|
||||
|
||||
useAutoConnect(accountExists, pubkey, showCreateModal, showUnlockModal, connect)
|
||||
|
||||
if (connected && pubkey && isUnlocked) {
|
||||
return <ConnectedState pubkey={pubkey} profile={profile} loading={loading} disconnect={disconnect} />
|
||||
}
|
||||
|
||||
if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal && !showCreateModal) {
|
||||
return (
|
||||
<UnlockState
|
||||
loading={loading}
|
||||
error={error}
|
||||
onUnlock={() => setShowUnlockModal(true)}
|
||||
onClose={() => setShowUnlockModal(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConnectForm
|
||||
onConnect={() => {
|
||||
void connect()
|
||||
}}
|
||||
<DisconnectedState
|
||||
loading={loading}
|
||||
error={error}
|
||||
showCreateModal={showCreateModal}
|
||||
showUnlockModal={showUnlockModal}
|
||||
setShowCreateModal={setShowCreateModal}
|
||||
setShowUnlockModal={setShowUnlockModal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 { t } from '@/lib/i18n'
|
||||
|
||||
interface FundingProgressBarProps {
|
||||
progressPercent: number
|
||||
}
|
||||
|
||||
function FundingProgressBar({ progressPercent }: FundingProgressBarProps) {
|
||||
return (
|
||||
<div className="relative w-full h-4 bg-cyber-dark rounded-full overflow-hidden border border-neon-cyan/30">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-gradient-to-r from-neon-cyan to-neon-green transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-mono text-cyber-darker font-bold">
|
||||
{progressPercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FundingStats({ stats }: { stats: ReturnType<typeof estimatePlatformFunds> }) {
|
||||
const progressPercent = Math.min(100, stats.progressPercent)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-cyber-accent">{t('home.funding.current', { current: stats.totalBTC.toFixed(6) })}</span>
|
||||
<span className="text-cyber-accent">{t('home.funding.target', { target: stats.targetBTC.toFixed(2) })}</span>
|
||||
</div>
|
||||
<FundingProgressBar progressPercent={progressPercent} />
|
||||
<p className="text-xs text-cyber-accent/70">
|
||||
{t('home.funding.progress', { percent: progressPercent.toFixed(1) })}
|
||||
</p>
|
||||
<p className="text-sm text-cyber-accent mt-4">
|
||||
{t('home.funding.description')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FundingGauge() {
|
||||
const [stats, setStats] = useState(estimatePlatformFunds())
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -30,38 +69,10 @@ export function FundingGauge() {
|
||||
)
|
||||
}
|
||||
|
||||
const progressPercent = Math.min(100, stats.progressPercent)
|
||||
|
||||
return (
|
||||
<div className="bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-6 mb-8">
|
||||
<h2 className="text-xl font-semibold text-neon-cyan mb-4">{t('home.funding.title')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-cyber-accent">{t('home.funding.current', { current: stats.totalBTC.toFixed(6) })}</span>
|
||||
<span className="text-cyber-accent">{t('home.funding.target', { target: stats.targetBTC.toFixed(2) })}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-4 bg-cyber-dark rounded-full overflow-hidden border border-neon-cyan/30">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-gradient-to-r from-neon-cyan to-neon-green transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-mono text-cyber-darker font-bold">
|
||||
{progressPercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-cyber-accent/70">
|
||||
{t('home.funding.progress', { percent: progressPercent.toFixed(1) })}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-cyber-accent mt-4">
|
||||
{t('home.funding.description')}
|
||||
</p>
|
||||
</div>
|
||||
<FundingStats stats={stats} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -59,10 +59,10 @@ function HomeIntroSection() {
|
||||
<div className="mt-12 mb-8">
|
||||
<div className="mb-6 text-cyber-accent leading-relaxed bg-cyber-dark/50 border border-neon-cyan/20 rounded-lg p-4 backdrop-blur-sm">
|
||||
<p className="mb-2">
|
||||
Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par <strong className="text-neon-green">800 sats</strong> (moins 100 sats et frais de transaction).
|
||||
Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par <strong className="text-neon-green">800 sats</strong> (moins 100 sats et frais de transaction).
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
Sponsorisez l'auteur pour <strong className="text-neon-green">0.046 BTC</strong> (moins 0.004 BTC et frais de transaction).
|
||||
Sponsorisez l'auteur pour <strong className="text-neon-green">0.046 BTC</strong> (moins 0.004 BTC et frais de transaction).
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
Les avis sont remerciables pour <strong className="text-neon-green">70 sats</strong> (moins 21 sats et frais de transaction).
|
||||
|
||||
@ -11,7 +11,87 @@ interface ImageUploadFieldProps {
|
||||
helpText?: string | undefined
|
||||
}
|
||||
|
||||
export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps) {
|
||||
function ImagePreview({ value }: { value: string }) {
|
||||
return (
|
||||
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20">
|
||||
<Image
|
||||
src={value}
|
||||
alt={t('presentation.field.picture')}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadButtonLabel({ uploading, value }: { uploading: boolean; value: string | undefined }) {
|
||||
if (uploading) {
|
||||
return <>{t('presentation.field.picture.uploading')}</>
|
||||
}
|
||||
return <>{value ? t('presentation.field.picture.change') : t('presentation.field.picture.upload')}</>
|
||||
}
|
||||
|
||||
function RemoveButton({ value, onChange }: { value: string | undefined; onChange: (url: string) => void }) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50"
|
||||
>
|
||||
{t('presentation.field.picture.remove')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageUploadControls({
|
||||
id,
|
||||
uploading,
|
||||
value,
|
||||
onChange,
|
||||
onFileSelect,
|
||||
}: {
|
||||
id: string
|
||||
uploading: boolean
|
||||
value: string | undefined
|
||||
onChange: (url: string) => void
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan cursor-pointer"
|
||||
>
|
||||
<UploadButtonLabel uploading={uploading} value={value} />
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void onFileSelect(e)
|
||||
}}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<RemoveButton value={value} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function processFileUpload(file: File, onChange: (url: string) => void, setError: (error: string | null) => void) {
|
||||
const media = await uploadNip95Media(file)
|
||||
if (media.type === 'image') {
|
||||
onChange(media.url)
|
||||
} else {
|
||||
setError(t('presentation.field.picture.error.imagesOnly'))
|
||||
}
|
||||
}
|
||||
|
||||
function useImageUpload(onChange: (url: string) => void) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@ -25,12 +105,7 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
|
||||
setUploading(true)
|
||||
|
||||
try {
|
||||
const media = await uploadNip95Media(file)
|
||||
if (media.type === 'image') {
|
||||
onChange(media.url)
|
||||
} else {
|
||||
setError(t('presentation.field.picture.error.imagesOnly'))
|
||||
}
|
||||
await processFileUpload(file, onChange, setError)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('presentation.field.picture.error.uploadFailed'))
|
||||
} finally {
|
||||
@ -38,6 +113,11 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
|
||||
}
|
||||
}
|
||||
|
||||
return { uploading, error, handleFileSelect }
|
||||
}
|
||||
|
||||
export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps) {
|
||||
const { uploading, error, handleFileSelect } = useImageUpload(onChange)
|
||||
const displayLabel = label ?? t('presentation.field.picture')
|
||||
const displayHelpText = helpText ?? t('presentation.field.picture.help')
|
||||
|
||||
@ -46,47 +126,16 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
|
||||
<label htmlFor={id} className="block text-sm font-medium text-neon-cyan">
|
||||
{displayLabel}
|
||||
</label>
|
||||
{value && (
|
||||
<div className="relative w-32 h-32 rounded-lg overflow-hidden border border-neon-cyan/20">
|
||||
<Image
|
||||
src={value}
|
||||
alt={t('presentation.field.picture')}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan cursor-pointer"
|
||||
>
|
||||
{uploading ? t('presentation.field.picture.uploading') : value ? t('presentation.field.picture.change') : t('presentation.field.picture.upload')}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={uploading}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50"
|
||||
>
|
||||
{t('presentation.field.picture.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
{displayHelpText && (
|
||||
<p className="text-sm text-cyber-accent">{displayHelpText}</p>
|
||||
)}
|
||||
{value && <ImagePreview value={value} />}
|
||||
<ImageUploadControls
|
||||
id={id}
|
||||
uploading={uploading}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
{displayHelpText && <p className="text-sm text-cyber-accent">{displayHelpText}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,6 +3,29 @@ import { setLocale, getLocale, type Locale } from '@/lib/i18n'
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'zapwall-locale'
|
||||
|
||||
interface LocaleButtonProps {
|
||||
locale: Locale
|
||||
label: string
|
||||
currentLocale: Locale
|
||||
onClick: (locale: Locale) => void
|
||||
}
|
||||
|
||||
function LocaleButton({ locale, label, currentLocale, onClick }: LocaleButtonProps) {
|
||||
const isActive = currentLocale === locale
|
||||
return (
|
||||
<button
|
||||
onClick={() => onClick(locale)}
|
||||
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
isActive
|
||||
? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
|
||||
: 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function LanguageSelector() {
|
||||
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
|
||||
|
||||
@ -27,26 +50,8 @@ export function LanguageSelector() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleLocaleChange('fr')}
|
||||
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
currentLocale === 'fr'
|
||||
? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
|
||||
: 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
|
||||
}`}
|
||||
>
|
||||
FR
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLocaleChange('en')}
|
||||
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
currentLocale === 'en'
|
||||
? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
|
||||
: 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<LocaleButton locale="fr" label="FR" currentLocale={currentLocale} onClick={handleLocaleChange} />
|
||||
<LocaleButton locale="en" label="EN" currentLocale={currentLocale} onClick={handleLocaleChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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é ?
|
||||
|
||||
Par défaut, l'application utilise `wss://relay.damus.io`. Vous pouvez configurer un autre relay via la variable d'environnement `NEXT_PUBLIC_NOSTR_RELAY_URL`.
|
||||
Par défaut, l'application utilise `wss://relay.damus.io`. La configuration des relais est stockée dans IndexedDB (stockage local du navigateur) et peut être personnalisée via les paramètres de l'application. L'application supporte plusieurs relais avec un système de priorité.
|
||||
|
||||
### Les données sont-elles stockées sur un serveur ?
|
||||
|
||||
@ -287,7 +287,7 @@ Le contenu peut être perdu. Vous devrez peut-être payer à nouveau pour déblo
|
||||
|
||||
### Puis-je contacter le support ?
|
||||
|
||||
Pour l'instant, il n'y a pas de support officiel. Consultez la documentation ou créez une issue sur le dépôt GitHub du projet.
|
||||
Pour l'instant, il n'y a pas de support officiel. Consultez la documentation ou créez une issue sur le [dépôt Gitea du projet](https://git.4nkweb.com/4nk/story-research-zapwall/issues).
|
||||
|
||||
---
|
||||
|
||||
|
||||
66
docs/quick-reference.md
Normal file
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 [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [accountExists, setAccountExists] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = nostrAuthService.subscribe((newState) => {
|
||||
setState(newState)
|
||||
})
|
||||
|
||||
// Check if account exists on mount
|
||||
nostrAuthService.accountExists().then(setAccountExists).catch(() => setAccountExists(false))
|
||||
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
@ -44,5 +48,7 @@ export function useNostrAuth() {
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
accountExists,
|
||||
isUnlocked: nostrAuthService.isUnlocked(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +58,21 @@ function arrayBufferToHex(buffer: ArrayBuffer): string {
|
||||
* Encrypt article content with AES-GCM
|
||||
* Returns encrypted content, IV, and the encryption key
|
||||
*/
|
||||
async function prepareEncryptionKey(key: string): Promise<CryptoKey> {
|
||||
const keyBuffer = hexToArrayBuffer(key)
|
||||
return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt'])
|
||||
}
|
||||
|
||||
function prepareIV(iv: Uint8Array): { view: Uint8Array; buffer: ArrayBuffer } {
|
||||
const ivBuffer = new ArrayBuffer(iv.byteLength)
|
||||
const ivView = new Uint8Array(ivBuffer)
|
||||
// Copy bytes from original IV
|
||||
for (let i = 0; i < iv.length; i++) {
|
||||
ivView[i] = iv[i] ?? 0
|
||||
}
|
||||
return { view: ivView, buffer: ivBuffer }
|
||||
}
|
||||
|
||||
export async function encryptArticleContent(content: string): Promise<{
|
||||
encryptedContent: string
|
||||
key: string
|
||||
@ -65,39 +80,21 @@ export async function encryptArticleContent(content: string): Promise<{
|
||||
}> {
|
||||
const key = generateEncryptionKey()
|
||||
const iv = generateIV()
|
||||
const keyBuffer = hexToArrayBuffer(key)
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
|
||||
const cryptoKey = await prepareEncryptionKey(key)
|
||||
const encoder = new TextEncoder()
|
||||
const encodedContent = encoder.encode(content)
|
||||
|
||||
const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength)
|
||||
const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength)
|
||||
ivView.set(iv)
|
||||
const { view: ivView, buffer: ivBuffer } = prepareIV(iv)
|
||||
|
||||
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: ivView,
|
||||
},
|
||||
{ name: 'AES-GCM', iv: ivView as Uint8Array<ArrayBuffer> },
|
||||
cryptoKey,
|
||||
encodedContent
|
||||
)
|
||||
|
||||
const encryptedContent = arrayBufferToHex(encryptedBuffer)
|
||||
const ivHex = arrayBufferToHex(ivView.buffer)
|
||||
|
||||
return {
|
||||
encryptedContent,
|
||||
encryptedContent: arrayBufferToHex(encryptedBuffer),
|
||||
key,
|
||||
iv: ivHex,
|
||||
iv: arrayBufferToHex(ivBuffer),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -163,6 +163,48 @@ function buildReviewEvent(
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdateTags(draft: ArticleDraft, originalArticleId: string, newCategory: 'sciencefiction' | 'research') {
|
||||
const updateTags = buildTags({
|
||||
type: 'publication',
|
||||
category: newCategory,
|
||||
id: '', // Will be set to event.id after publication
|
||||
paywall: true,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
zapAmount: draft.zapAmount,
|
||||
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
||||
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
||||
})
|
||||
updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
|
||||
return updateTags
|
||||
}
|
||||
|
||||
async function publishUpdate(
|
||||
draft: ArticleDraft,
|
||||
authorPubkey: string,
|
||||
originalArticleId: string
|
||||
): Promise<ArticleUpdateResult> {
|
||||
const category = draft.category
|
||||
requireCategory(category)
|
||||
const presentationId = await ensurePresentation(authorPubkey)
|
||||
const invoice = await createArticleInvoice(draft)
|
||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
||||
const updateTags = buildUpdateTags(draft, originalArticleId, newCategory)
|
||||
|
||||
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
|
||||
if (!publishedEvent) {
|
||||
return updateFailure(originalArticleId, 'Failed to publish article update')
|
||||
}
|
||||
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
|
||||
return {
|
||||
articleId: publishedEvent.id,
|
||||
previewEventId: publishedEvent.id,
|
||||
invoice,
|
||||
success: true,
|
||||
originalArticleId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishArticleUpdate(
|
||||
originalArticleId: string,
|
||||
draft: ArticleDraft,
|
||||
@ -171,41 +213,7 @@ export async function publishArticleUpdate(
|
||||
): Promise<ArticleUpdateResult> {
|
||||
try {
|
||||
ensureKeys(authorPubkey, authorPrivateKey)
|
||||
const category = draft.category
|
||||
requireCategory(category)
|
||||
const presentationId = await ensurePresentation(authorPubkey)
|
||||
const invoice = await createArticleInvoice(draft)
|
||||
// Map category to new system
|
||||
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
|
||||
|
||||
// Build tags using new system (update event references original)
|
||||
const updateTags = buildTags({
|
||||
type: 'publication',
|
||||
category: newCategory,
|
||||
id: '', // Will be set to event.id after publication
|
||||
paywall: true,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
zapAmount: draft.zapAmount,
|
||||
...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
|
||||
...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
|
||||
})
|
||||
|
||||
// Add reference to original article
|
||||
updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
|
||||
|
||||
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
|
||||
if (!publishedEvent) {
|
||||
return updateFailure(originalArticleId, 'Failed to publish article update')
|
||||
}
|
||||
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
|
||||
return {
|
||||
articleId: publishedEvent.id,
|
||||
previewEventId: publishedEvent.id,
|
||||
invoice,
|
||||
success: true,
|
||||
originalArticleId,
|
||||
}
|
||||
return await publishUpdate(draft, authorPubkey, originalArticleId)
|
||||
} catch (error) {
|
||||
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
|
||||
@ -1,48 +1,13 @@
|
||||
import { nostrService } from './nostr'
|
||||
import type { AlbyInvoice } from '@/types/alby'
|
||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||
import type { MediaRef } from '@/types/nostr'
|
||||
import {
|
||||
storePrivateContent,
|
||||
getStoredPrivateContent,
|
||||
getStoredInvoice,
|
||||
removeStoredPrivateContent,
|
||||
} from './articleStorage'
|
||||
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
|
||||
import type { AlbyInvoice } from '@/types/alby'
|
||||
import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage'
|
||||
import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers'
|
||||
import {
|
||||
encryptArticleContent,
|
||||
encryptDecryptionKey,
|
||||
} from './articleEncryption'
|
||||
import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
||||
import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation'
|
||||
import { buildFailure, encryptAndPublish } from './articlePublisherPublish'
|
||||
|
||||
export interface ArticleDraft {
|
||||
title: string
|
||||
preview: string
|
||||
content: string // Full content that will be sent as private message after payment
|
||||
zapAmount: number
|
||||
category?: 'science-fiction' | 'scientific-research'
|
||||
seriesId?: string
|
||||
bannerUrl?: string
|
||||
media?: MediaRef[]
|
||||
}
|
||||
|
||||
export interface AuthorPresentationDraft {
|
||||
title: string
|
||||
preview: string
|
||||
content: string
|
||||
presentation: string
|
||||
contentDescription: string
|
||||
mainnetAddress: string
|
||||
pictureUrl?: string | undefined
|
||||
}
|
||||
|
||||
export interface PublishedArticle {
|
||||
articleId: string
|
||||
previewEventId: string
|
||||
invoice?: AlbyInvoice // Invoice created by author (required if success)
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
|
||||
|
||||
/**
|
||||
* Service for publishing articles on Nostr
|
||||
@ -51,61 +16,39 @@ export interface PublishedArticle {
|
||||
export class ArticlePublisher {
|
||||
// Removed unused siteTag - using new tag system instead
|
||||
|
||||
private buildFailure(error?: string): PublishedArticle {
|
||||
const base: PublishedArticle = {
|
||||
articleId: '',
|
||||
previewEventId: '',
|
||||
success: false,
|
||||
}
|
||||
return error ? { ...base, error } : base
|
||||
}
|
||||
|
||||
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
|
||||
nostrService.setPublicKey(authorPubkey)
|
||||
|
||||
if (authorPrivateKey) {
|
||||
nostrService.setPrivateKey(authorPrivateKey)
|
||||
return { success: true }
|
||||
private async validatePublishRequest(
|
||||
draft: ArticleDraft,
|
||||
authorPubkey: string,
|
||||
authorPrivateKey?: string
|
||||
): Promise<PublishValidationResult> {
|
||||
const keySetup = prepareAuthorKeys(authorPubkey, authorPrivateKey)
|
||||
if (!keySetup.success) {
|
||||
return { success: false, error: keySetup.error ?? 'Key setup failed' }
|
||||
}
|
||||
|
||||
const existingPrivateKey = nostrService.getPrivateKey()
|
||||
if (!existingPrivateKey) {
|
||||
const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
|
||||
if (!authorPrivateKeyForEncryption) {
|
||||
return { success: false, error: 'Private key required for encryption' }
|
||||
}
|
||||
|
||||
const presentation = await this.getAuthorPresentation(authorPubkey)
|
||||
if (!presentation) {
|
||||
return { success: false, error: 'Vous devez créer un article de présentation avant de publier des articles.' }
|
||||
}
|
||||
|
||||
if (!isValidCategory(draft.category)) {
|
||||
return { success: false, error: 'Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).' }
|
||||
}
|
||||
|
||||
const expectedAmount = 800
|
||||
if (draft.zapAmount !== expectedAmount) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
|
||||
error: `Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
private isValidCategory(category?: ArticleDraft['category']): category is NonNullable<ArticleDraft['category']> {
|
||||
return category === 'science-fiction' || category === 'scientific-research'
|
||||
}
|
||||
|
||||
private async publishPreview(
|
||||
draft: ArticleDraft,
|
||||
invoice: AlbyInvoice,
|
||||
presentationId: string,
|
||||
extraTags?: string[][],
|
||||
encryptedContent?: string,
|
||||
encryptedKey?: string
|
||||
): Promise<import('nostr-tools').Event | null> {
|
||||
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
|
||||
const publishedEvent = await nostrService.publishEvent(previewEvent)
|
||||
return publishedEvent ?? null
|
||||
}
|
||||
|
||||
private buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable<ArticleDraft['category']>): string[][] {
|
||||
// Media tags are still supported in the new system
|
||||
const extraTags: string[][] = []
|
||||
if (draft.media && draft.media.length > 0) {
|
||||
draft.media.forEach((m) => {
|
||||
extraTags.push(['media', m.url, m.type])
|
||||
})
|
||||
}
|
||||
return extraTags
|
||||
return { success: true, authorPrivateKeyForEncryption, category: draft.category }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,67 +62,20 @@ export class ArticlePublisher {
|
||||
authorPrivateKey?: string
|
||||
): Promise<PublishedArticle> {
|
||||
try {
|
||||
const keySetup = this.prepareAuthorKeys(authorPubkey, authorPrivateKey)
|
||||
if (!keySetup.success) {
|
||||
return this.buildFailure(keySetup.error)
|
||||
}
|
||||
|
||||
const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
|
||||
if (!authorPrivateKeyForEncryption) {
|
||||
return this.buildFailure('Private key required for encryption')
|
||||
const validation = await this.validatePublishRequest(draft, authorPubkey, authorPrivateKey)
|
||||
if (!validation.success) {
|
||||
return buildFailure(validation.error)
|
||||
}
|
||||
|
||||
const presentation = await this.getAuthorPresentation(authorPubkey)
|
||||
if (!presentation) {
|
||||
return this.buildFailure('Vous devez créer un article de présentation avant de publier des articles.')
|
||||
return buildFailure('Presentation not found')
|
||||
}
|
||||
|
||||
if (!this.isValidCategory(draft.category)) {
|
||||
return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
|
||||
}
|
||||
const category = draft.category
|
||||
|
||||
// Verify zap amount matches expected commission structure
|
||||
const expectedAmount = 800 // PLATFORM_COMMISSIONS.article.total
|
||||
if (draft.zapAmount !== expectedAmount) {
|
||||
return this.buildFailure(
|
||||
`Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`
|
||||
)
|
||||
}
|
||||
|
||||
// Encrypt the article content
|
||||
const { encryptedContent, key, iv } = await encryptArticleContent(draft.content)
|
||||
|
||||
// Encrypt the decryption key with the author's public key (for storage in tags)
|
||||
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
|
||||
|
||||
const invoice = await createArticleInvoice(draft)
|
||||
const extraTags = this.buildArticleExtraTags(draft, category)
|
||||
const publishedEvent = await this.publishPreview(
|
||||
draft,
|
||||
invoice,
|
||||
presentation.id,
|
||||
extraTags,
|
||||
encryptedContent,
|
||||
encryptedKey
|
||||
)
|
||||
if (!publishedEvent) {
|
||||
return this.buildFailure('Failed to publish article')
|
||||
}
|
||||
|
||||
// Store the decryption key locally for sending after payment
|
||||
await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice, key, iv)
|
||||
|
||||
console.log('Article published with encrypted content', {
|
||||
articleId: publishedEvent.id,
|
||||
authorPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true }
|
||||
return await encryptAndPublish(draft, authorPubkey, validation.authorPrivateKeyForEncryption, validation.category, presentation.id)
|
||||
} catch (error) {
|
||||
console.error('Error publishing article:', error)
|
||||
return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
||||
return buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
@ -208,6 +104,25 @@ export class ArticlePublisher {
|
||||
* Send private content to a user after payment confirmation
|
||||
* Returns detailed result with message event ID and verification status
|
||||
*/
|
||||
private logSendResult(result: import('./articlePublisherHelpers').SendContentResult, articleId: string, recipientPubkey: string) {
|
||||
if (result.success) {
|
||||
console.log('Private content sent successfully', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
messageEventId: result.messageEventId,
|
||||
verified: result.verified,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to send private content', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
error: result.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async sendPrivateContent(
|
||||
articleId: string,
|
||||
recipientPubkey: string,
|
||||
@ -218,31 +133,11 @@ export class ArticlePublisher {
|
||||
if (!stored) {
|
||||
const error = 'Private content not found for article'
|
||||
console.error(error, { articleId, recipientPubkey })
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
}
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
const result = await sendEncryptedContent(articleId, recipientPubkey, stored, authorPrivateKey)
|
||||
|
||||
if (result.success) {
|
||||
console.log('Private content sent successfully', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
messageEventId: result.messageEventId,
|
||||
verified: result.verified,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to send private content', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
error: result.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
this.logSendResult(result, articleId, recipientPubkey)
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
@ -252,10 +147,7 @@ export class ArticlePublisher {
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
}
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
@ -280,11 +172,11 @@ export class ArticlePublisher {
|
||||
nostrService.setPrivateKey(authorPrivateKey)
|
||||
|
||||
// Generate event ID before building event (using a temporary ID that will be replaced by Nostr)
|
||||
const tempEventId = 'temp_' + Math.random().toString(36).substring(7)
|
||||
const tempEventId = `temp_${Math.random().toString(36).substring(7)}`
|
||||
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction'))
|
||||
|
||||
if (!publishedEvent) {
|
||||
return this.buildFailure('Failed to publish presentation article')
|
||||
return buildFailure('Failed to publish presentation article')
|
||||
}
|
||||
|
||||
return {
|
||||
@ -294,7 +186,7 @@ export class ArticlePublisher {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error publishing presentation article:', error)
|
||||
return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
||||
return buildFailure(error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,271 +1,3 @@
|
||||
import { nip04, type Event } from 'nostr-tools'
|
||||
import { nostrService } from './nostr'
|
||||
import type { AuthorPresentationDraft } from './articlePublisher'
|
||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
|
||||
export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: string, category: 'sciencefiction' | 'research' = 'sciencefiction') {
|
||||
return {
|
||||
kind: 1 as const,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: buildTags({
|
||||
type: 'author',
|
||||
category,
|
||||
id: eventId,
|
||||
paywall: false,
|
||||
title: draft.title,
|
||||
preview: draft.preview,
|
||||
mainnetAddress: draft.mainnetAddress,
|
||||
totalSponsoring: 0,
|
||||
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
|
||||
}),
|
||||
content: draft.content,
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePresentationEvent(event: Event): import('@/types/nostr').AuthorPresentationArticle | null {
|
||||
const tags = extractTagsFromEvent(event)
|
||||
|
||||
// Check if it's an author type (tag is 'author' in English)
|
||||
if (tags.type !== 'author') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: tags.id ?? event.id,
|
||||
pubkey: event.pubkey,
|
||||
title: (tags.title as string | undefined) ?? 'Présentation',
|
||||
preview: (tags.preview as string | undefined) ?? event.content.substring(0, 200),
|
||||
content: event.content,
|
||||
createdAt: event.created_at,
|
||||
zapAmount: 0,
|
||||
paid: true,
|
||||
category: 'author-presentation',
|
||||
isPresentation: true,
|
||||
mainnetAddress: (tags.mainnetAddress as string | undefined) ?? '',
|
||||
totalSponsoring: (tags.totalSponsoring as number | undefined) ?? 0,
|
||||
...(tags.pictureUrl ? { bannerUrl: tags.pictureUrl as string } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchAuthorPresentationFromPool(
|
||||
pool: SimplePoolWithSub,
|
||||
pubkey: string
|
||||
): Promise<import('@/types/nostr').AuthorPresentationArticle | null> {
|
||||
const filters = [
|
||||
{
|
||||
...buildTagFilter({
|
||||
type: 'author',
|
||||
authorPubkey: pubkey,
|
||||
}),
|
||||
limit: 1,
|
||||
},
|
||||
]
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
const sub = pool.sub([RELAY_URL], filters)
|
||||
|
||||
const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
sub.unsub()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
const parsed = parsePresentationEvent(event)
|
||||
if (parsed) {
|
||||
finalize(parsed)
|
||||
}
|
||||
})
|
||||
|
||||
sub.on('eose', () => finalize(null))
|
||||
setTimeout(() => finalize(null), 5000)
|
||||
})
|
||||
}
|
||||
|
||||
export interface SendContentResult {
|
||||
success: boolean
|
||||
messageEventId?: string
|
||||
error?: string
|
||||
verified?: boolean
|
||||
}
|
||||
|
||||
export async function sendEncryptedContent(
|
||||
articleId: string,
|
||||
recipientPubkey: string,
|
||||
storedContent: { content: string; authorPubkey: string; decryptionKey?: string; decryptionIV?: string },
|
||||
authorPrivateKey: string
|
||||
): Promise<SendContentResult> {
|
||||
try {
|
||||
nostrService.setPrivateKey(authorPrivateKey)
|
||||
nostrService.setPublicKey(storedContent.authorPubkey)
|
||||
|
||||
// Send the decryption key instead of the full content
|
||||
// The key is sent as JSON: { key: string, iv: string }
|
||||
const keyData = storedContent.decryptionKey && storedContent.decryptionIV
|
||||
? JSON.stringify({ key: storedContent.decryptionKey, iv: storedContent.decryptionIV })
|
||||
: storedContent.content // Fallback to old behavior if keys are not available
|
||||
|
||||
const encryptedKey = await Promise.resolve(nip04.encrypt(authorPrivateKey, recipientPubkey, keyData))
|
||||
|
||||
const privateMessageEvent = {
|
||||
kind: 4,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['p', recipientPubkey],
|
||||
['e', articleId],
|
||||
],
|
||||
content: encryptedKey,
|
||||
}
|
||||
|
||||
const publishedEvent = await nostrService.publishEvent(privateMessageEvent)
|
||||
|
||||
if (!publishedEvent) {
|
||||
console.error('Failed to publish private message event', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
authorPubkey: storedContent.authorPubkey,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to publish private message event',
|
||||
}
|
||||
}
|
||||
|
||||
const messageEventId = publishedEvent.id
|
||||
console.log('Private message published', {
|
||||
messageEventId,
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
authorPubkey: storedContent.authorPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
const verified = await verifyPrivateMessagePublished(messageEventId, storedContent.authorPubkey, recipientPubkey, articleId)
|
||||
|
||||
if (verified) {
|
||||
console.log('Private message verified on relay', {
|
||||
messageEventId,
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
})
|
||||
} else {
|
||||
console.warn('Private message published but not yet verified on relay', {
|
||||
messageEventId,
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageEventId,
|
||||
verified,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error('Error sending encrypted content', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
authorPubkey: storedContent.authorPubkey,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPrivateMessagePublished(
|
||||
messageEventId: string,
|
||||
authorPubkey: string,
|
||||
recipientPubkey: string,
|
||||
articleId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
console.error('Pool not initialized for message verification', {
|
||||
messageEventId,
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
const filters = [
|
||||
{
|
||||
kinds: [4],
|
||||
ids: [messageEventId],
|
||||
authors: [authorPubkey],
|
||||
'#p': [recipientPubkey],
|
||||
'#e': [articleId],
|
||||
limit: 1,
|
||||
},
|
||||
]
|
||||
|
||||
const sub = (pool as import('@/types/nostr-tools-extended').SimplePoolWithSub).sub([RELAY_URL], filters)
|
||||
|
||||
const finalize = (value: boolean) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
sub.unsub()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
sub.on('event', (event) => {
|
||||
console.log('Private message verified on relay', {
|
||||
messageEventId: event.id,
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
authorPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
finalize(true)
|
||||
})
|
||||
|
||||
sub.on('eose', () => {
|
||||
console.warn('Private message not found on relay after EOSE', {
|
||||
messageEventId,
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
finalize(false)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
console.warn('Timeout verifying private message on relay', {
|
||||
messageEventId,
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
finalize(false)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error verifying private message', {
|
||||
messageEventId,
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
export { buildPresentationEvent, parsePresentationEvent, fetchAuthorPresentationFromPool } from './articlePublisherHelpersPresentation'
|
||||
export { sendEncryptedContent, type SendContentResult } from './articlePublisherHelpersEncryption'
|
||||
export { verifyPrivateMessagePublished } from './articlePublisherHelpersVerification'
|
||||
|
||||
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 { parseArticleFromEvent } from './nostrEventParsing'
|
||||
import { buildTagFilter } from './nostrTagSystem'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
|
||||
export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000, limit: number = 100): Promise<Article[]> {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
throw new Error('Pool not initialized')
|
||||
}
|
||||
const poolWithSub = pool as SimplePoolWithSub
|
||||
function createSeriesSubscription(poolWithSub: SimplePoolWithSub, seriesId: string, limit: number) {
|
||||
const filters = [
|
||||
{
|
||||
...buildTagFilter({
|
||||
@ -22,10 +16,20 @@ export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000,
|
||||
limit,
|
||||
},
|
||||
]
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
return poolWithSub.sub([relayUrl], filters)
|
||||
}
|
||||
|
||||
export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000, limit: number = 100): Promise<Article[]> {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
throw new Error('Pool not initialized')
|
||||
}
|
||||
const poolWithSub = pool as SimplePoolWithSub
|
||||
const sub = createSeriesSubscription(poolWithSub, seriesId, limit)
|
||||
|
||||
return new Promise<Article[]>((resolve) => {
|
||||
const results: Article[] = []
|
||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
||||
let finished = false
|
||||
|
||||
const done = () => {
|
||||
|
||||
@ -22,6 +22,28 @@ export class AutomaticTransferService {
|
||||
* Transfer author portion after article payment
|
||||
* Creates a Lightning invoice from the platform to the author
|
||||
*/
|
||||
private logTransferRequired(type: 'article' | 'review', id: string, pubkey: string, amount: number, recipient: string, platformCommission: number) {
|
||||
const logData = {
|
||||
[type === 'article' ? 'articleId' : 'reviewId']: id,
|
||||
[type === 'article' ? 'articlePubkey' : 'reviewerPubkey']: pubkey,
|
||||
amount,
|
||||
recipient,
|
||||
platformCommission,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
console.log(`Automatic transfer required${type === 'review' ? ' for review' : ''}`, logData)
|
||||
}
|
||||
|
||||
private buildTransferError(error: unknown, recipient: string, amount: number = 0): TransferResult {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
amount,
|
||||
recipient,
|
||||
}
|
||||
}
|
||||
|
||||
async transferAuthorPortion(
|
||||
authorLightningAddress: string,
|
||||
articleId: string,
|
||||
@ -40,23 +62,8 @@ export class AutomaticTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Create a Lightning invoice from platform to author
|
||||
// 2. Pay the invoice automatically
|
||||
// 3. Track the transfer
|
||||
|
||||
// For now, we log the transfer that should be made
|
||||
console.log('Automatic transfer required', {
|
||||
articleId,
|
||||
articlePubkey,
|
||||
amount: split.author,
|
||||
recipient: authorLightningAddress,
|
||||
platformCommission: split.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Track the transfer requirement
|
||||
await this.trackTransferRequirement('article', articleId, articlePubkey, split.author, authorLightningAddress)
|
||||
this.logTransferRequired('article', articleId, articlePubkey, split.author, authorLightningAddress, split.platform)
|
||||
this.trackTransferRequirement('article', articleId, articlePubkey, split.author, authorLightningAddress)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@ -64,19 +71,13 @@ export class AutomaticTransferService {
|
||||
recipient: authorLightningAddress,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error('Error transferring author portion', {
|
||||
articleId,
|
||||
articlePubkey,
|
||||
error: errorMessage,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
amount: 0,
|
||||
recipient: authorLightningAddress,
|
||||
}
|
||||
return this.buildTransferError(error, authorLightningAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,16 +102,8 @@ export class AutomaticTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Automatic transfer required for review', {
|
||||
reviewId,
|
||||
reviewerPubkey,
|
||||
amount: split.reviewer,
|
||||
recipient: reviewerLightningAddress,
|
||||
platformCommission: split.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
await this.trackTransferRequirement('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress)
|
||||
this.logTransferRequired('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress, split.platform)
|
||||
this.trackTransferRequirement('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@ -118,19 +111,13 @@ export class AutomaticTransferService {
|
||||
recipient: reviewerLightningAddress,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error('Error transferring reviewer portion', {
|
||||
reviewId,
|
||||
reviewerPubkey,
|
||||
error: errorMessage,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
amount: 0,
|
||||
recipient: reviewerLightningAddress,
|
||||
}
|
||||
return this.buildTransferError(error, reviewerLightningAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,13 +125,13 @@ export class AutomaticTransferService {
|
||||
* Track transfer requirement for later processing
|
||||
* In production, this would be stored in a database or queue
|
||||
*/
|
||||
private async trackTransferRequirement(
|
||||
private trackTransferRequirement(
|
||||
type: 'article' | 'review',
|
||||
id: string,
|
||||
recipientPubkey: string,
|
||||
amount: number,
|
||||
recipientAddress: string
|
||||
): Promise<void> {
|
||||
): void {
|
||||
// In production, this would:
|
||||
// 1. Store in a database/queue for processing
|
||||
// 2. Trigger automatic transfer via platform's Lightning node
|
||||
|
||||
97
lib/config.ts
Normal file
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 type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
|
||||
export interface ContentDeliveryStatus {
|
||||
messageEventId: string | null
|
||||
@ -15,7 +14,94 @@ export interface ContentDeliveryStatus {
|
||||
* Verify that private content was successfully delivered to recipient
|
||||
* Checks multiple aspects to ensure delivery certainty
|
||||
*/
|
||||
export async function verifyContentDelivery(
|
||||
function createContentDeliveryFilters(authorPubkey: string, recipientPubkey: string, articleId: string, messageEventId: string) {
|
||||
const filters: Array<{
|
||||
kinds: number[]
|
||||
ids?: string[]
|
||||
authors: string[]
|
||||
'#p': string[]
|
||||
'#e': string[]
|
||||
limit: number
|
||||
}> = [
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [authorPubkey],
|
||||
'#p': [recipientPubkey],
|
||||
'#e': [articleId],
|
||||
limit: 1,
|
||||
},
|
||||
]
|
||||
|
||||
if (messageEventId && filters[0]) {
|
||||
filters[0].ids = [messageEventId]
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
function setupContentDeliveryHandlers(
|
||||
sub: SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
|
||||
status: ContentDeliveryStatus,
|
||||
finalize: (result: ContentDeliveryStatus) => void,
|
||||
isResolved: () => boolean
|
||||
): void {
|
||||
sub.on('event', (event) => {
|
||||
status.published = true
|
||||
status.verifiedOnRelay = true
|
||||
status.messageEventId = event.id
|
||||
status.retrievable = true
|
||||
finalize(status)
|
||||
})
|
||||
|
||||
sub.on('eose', () => {
|
||||
if (!status.published) {
|
||||
status.error = 'Message not found on relay'
|
||||
}
|
||||
finalize(status)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isResolved()) {
|
||||
if (!status.published) {
|
||||
status.error = 'Timeout waiting for message verification'
|
||||
}
|
||||
finalize(status)
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function createContentDeliverySubscription(
|
||||
pool: SimplePoolWithSub,
|
||||
authorPubkey: string,
|
||||
recipientPubkey: string,
|
||||
articleId: string,
|
||||
messageEventId: string
|
||||
) {
|
||||
const filters = createContentDeliveryFilters(authorPubkey, recipientPubkey, articleId, messageEventId)
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
return pool.sub([relayUrl], filters)
|
||||
}
|
||||
|
||||
function createContentDeliveryPromise(
|
||||
sub: SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
|
||||
status: ContentDeliveryStatus
|
||||
): Promise<ContentDeliveryStatus> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
|
||||
const finalize = (result: ContentDeliveryStatus) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
sub.unsub()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
setupContentDeliveryHandlers(sub, status, finalize, () => resolved)
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyContentDelivery(
|
||||
articleId: string,
|
||||
authorPubkey: string,
|
||||
recipientPubkey: string,
|
||||
@ -32,72 +118,15 @@ export async function verifyContentDelivery(
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
status.error = 'Pool not initialized'
|
||||
return status
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
|
||||
const poolWithSub = pool as SimplePoolWithSub
|
||||
|
||||
const filters: Array<{
|
||||
kinds: number[]
|
||||
ids?: string[]
|
||||
authors: string[]
|
||||
'#p': string[]
|
||||
'#e': string[]
|
||||
limit: number
|
||||
}> = [
|
||||
{
|
||||
kinds: [4],
|
||||
authors: [authorPubkey],
|
||||
'#p': [recipientPubkey],
|
||||
'#e': [articleId],
|
||||
limit: 1,
|
||||
},
|
||||
]
|
||||
|
||||
if (messageEventId && filters[0]) {
|
||||
filters[0].ids = [messageEventId]
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
||||
|
||||
const finalize = (result: ContentDeliveryStatus) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
sub.unsub()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
sub.on('event', (event) => {
|
||||
status.published = true
|
||||
status.verifiedOnRelay = true
|
||||
status.messageEventId = event.id
|
||||
status.retrievable = true
|
||||
finalize(status)
|
||||
})
|
||||
|
||||
sub.on('eose', () => {
|
||||
if (!status.published) {
|
||||
status.error = 'Message not found on relay'
|
||||
}
|
||||
finalize(status)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
if (!status.published) {
|
||||
status.error = 'Timeout waiting for message verification'
|
||||
}
|
||||
finalize(status)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
const sub = createContentDeliverySubscription(poolWithSub, authorPubkey, recipientPubkey, articleId, messageEventId)
|
||||
return createContentDeliveryPromise(sub, status)
|
||||
} catch (error) {
|
||||
status.error = error instanceof Error ? error.message : 'Unknown error'
|
||||
return status
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,50 +22,53 @@ export interface FundingStats {
|
||||
* This is an approximation based on commission rates
|
||||
* Actual calculation would require querying all transactions
|
||||
*/
|
||||
export async function calculatePlatformFunds(_authorPubkeys: string[]): Promise<FundingStats> {
|
||||
let totalSats = 0
|
||||
|
||||
// Calculate article commissions (from zap receipts with kind_type: purchase)
|
||||
// Each article payment is 800 sats, platform gets 100 sats
|
||||
// This is an approximation - in reality we'd query all zap receipts
|
||||
async function calculateArticleCommissions(): Promise<number> {
|
||||
try {
|
||||
const articleCommissions = await aggregateZapSats({
|
||||
authorPubkey: '', // Empty to get all
|
||||
kindType: 'purchase',
|
||||
})
|
||||
// Estimate: assume 100 sats commission per purchase (800 total, 700 author, 100 platform)
|
||||
// This is simplified - actual calculation would need to track each payment
|
||||
totalSats += Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
|
||||
return Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
|
||||
} catch (e) {
|
||||
console.error('Error calculating article commissions:', e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate review commissions (from zap receipts with kind_type: review_tip)
|
||||
// Each review tip is 70 sats, platform gets 21 sats
|
||||
async function calculateReviewCommissions(): Promise<number> {
|
||||
try {
|
||||
const reviewCommissions = await aggregateZapSats({
|
||||
authorPubkey: '', // Empty to get all
|
||||
kindType: 'review_tip',
|
||||
})
|
||||
// Estimate: assume 21 sats commission per review tip (70 total, 49 reviewer, 21 platform)
|
||||
totalSats += Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
|
||||
return Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
|
||||
} catch (e) {
|
||||
console.error('Error calculating review commissions:', e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate sponsoring commissions (from zap receipts with kind_type: sponsoring)
|
||||
// Each sponsoring is 0.046 BTC, platform gets 0.004 BTC (400,000 sats)
|
||||
async function calculateSponsoringCommissions(): Promise<number> {
|
||||
try {
|
||||
const sponsoringCommissions = await aggregateZapSats({
|
||||
authorPubkey: '', // Empty to get all
|
||||
kindType: 'sponsoring',
|
||||
})
|
||||
// Estimate: assume 400,000 sats commission per sponsoring (4,600,000 total, 4,200,000 author, 400,000 platform)
|
||||
totalSats += Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
|
||||
return Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
|
||||
} catch (e) {
|
||||
console.error('Error calculating sponsoring commissions:', e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function calculatePlatformFunds(_authorPubkeys: string[]): Promise<FundingStats> {
|
||||
const [articleCommissions, reviewCommissions, sponsoringCommissions] = await Promise.all([
|
||||
calculateArticleCommissions(),
|
||||
calculateReviewCommissions(),
|
||||
calculateSponsoringCommissions(),
|
||||
])
|
||||
|
||||
const totalSats = articleCommissions + reviewCommissions + sponsoringCommissions
|
||||
const totalBTC = totalSats / 100_000_000
|
||||
const progressPercent = Math.min(100, (totalBTC / FUNDING_TARGET_BTC) * 100)
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ export function getLocale(): Locale {
|
||||
* Load translations from a flat text file
|
||||
* Format: key=value (one per line, empty lines and lines starting with # are ignored)
|
||||
*/
|
||||
export async function loadTranslations(locale: Locale, translationsText: string): Promise<void> {
|
||||
export function loadTranslations(locale: Locale, translationsText: string): void {
|
||||
const translationsMap: Translations = {}
|
||||
|
||||
const lines = translationsText.split('\n')
|
||||
|
||||
165
lib/keyManagement.ts
Normal file
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 { PLATFORM_BITCOIN_ADDRESS } from './platformConfig'
|
||||
import type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
|
||||
import { getTransaction } from './mempoolSpaceApi'
|
||||
import { verifySponsoringTransaction } from './mempoolSpaceVerification'
|
||||
import { waitForConfirmation } from './mempoolSpaceConfirmation'
|
||||
|
||||
const MEMPOOL_API_BASE = 'https://mempool.space/api'
|
||||
|
||||
export interface MempoolTransaction {
|
||||
txid: string
|
||||
vout: Array<{
|
||||
value: number // in sats
|
||||
scriptpubkey_address: string
|
||||
}>
|
||||
status: {
|
||||
confirmed: boolean
|
||||
block_height?: number
|
||||
block_hash?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface TransactionVerificationResult {
|
||||
valid: boolean
|
||||
confirmed: boolean
|
||||
confirmations: number
|
||||
authorOutput?: {
|
||||
address: string
|
||||
amount: number
|
||||
}
|
||||
platformOutput?: {
|
||||
address: string
|
||||
amount: number
|
||||
}
|
||||
error?: string | undefined
|
||||
}
|
||||
export type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
|
||||
|
||||
/**
|
||||
* Mempool.space API service
|
||||
* Used to verify Bitcoin mainnet transactions for sponsoring payments
|
||||
*/
|
||||
/**
|
||||
* Mempool.space API service
|
||||
* Used to verify Bitcoin mainnet transactions for sponsoring payments
|
||||
@ -40,27 +18,7 @@ export class MempoolSpaceService {
|
||||
* Fetch transaction from mempool.space
|
||||
*/
|
||||
async getTransaction(txid: string): Promise<MempoolTransaction | null> {
|
||||
try {
|
||||
const response = await fetch(`${MEMPOOL_API_BASE}/tx/${txid}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
console.warn('Transaction not found on mempool.space', { txid })
|
||||
return null
|
||||
}
|
||||
throw new Error(`Failed to fetch transaction: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const transaction = await response.json() as MempoolTransaction
|
||||
return transaction
|
||||
} catch (error) {
|
||||
console.error('Error fetching transaction from mempool.space', {
|
||||
txid,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return null
|
||||
}
|
||||
return await getTransaction(txid)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -71,115 +29,7 @@ export class MempoolSpaceService {
|
||||
txid: string,
|
||||
authorMainnetAddress: string
|
||||
): Promise<TransactionVerificationResult> {
|
||||
try {
|
||||
const transaction = await this.getTransaction(txid)
|
||||
|
||||
if (!transaction) {
|
||||
return {
|
||||
valid: false,
|
||||
confirmed: false,
|
||||
confirmations: 0,
|
||||
error: 'Transaction not found',
|
||||
}
|
||||
}
|
||||
|
||||
const split = calculateSponsoringSplit()
|
||||
const expectedAuthorAmount = split.authorSats
|
||||
const expectedPlatformAmount = split.platformSats
|
||||
|
||||
// Find outputs matching expected addresses and amounts
|
||||
const authorOutput = transaction.vout.find(
|
||||
(output) =>
|
||||
output.scriptpubkey_address === authorMainnetAddress &&
|
||||
output.value === expectedAuthorAmount
|
||||
)
|
||||
|
||||
const platformOutput = transaction.vout.find(
|
||||
(output) =>
|
||||
output.scriptpubkey_address === PLATFORM_BITCOIN_ADDRESS &&
|
||||
output.value === expectedPlatformAmount
|
||||
)
|
||||
|
||||
const valid = Boolean(authorOutput && platformOutput)
|
||||
const confirmed = transaction.status.confirmed
|
||||
const confirmations = confirmed && transaction.status.block_height
|
||||
? await this.getConfirmations(transaction.status.block_height)
|
||||
: 0
|
||||
|
||||
if (!valid) {
|
||||
console.error('Transaction verification failed', {
|
||||
txid,
|
||||
authorAddress: authorMainnetAddress,
|
||||
platformAddress: PLATFORM_BITCOIN_ADDRESS,
|
||||
expectedAuthorAmount,
|
||||
expectedPlatformAmount,
|
||||
actualOutputs: transaction.vout.map((o) => ({
|
||||
address: o.scriptpubkey_address,
|
||||
amount: o.value,
|
||||
})),
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
const result: TransactionVerificationResult = {
|
||||
valid,
|
||||
confirmed,
|
||||
confirmations,
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
result.error = 'Transaction outputs do not match expected split'
|
||||
}
|
||||
|
||||
if (authorOutput) {
|
||||
result.authorOutput = {
|
||||
address: authorOutput.scriptpubkey_address,
|
||||
amount: authorOutput.value,
|
||||
}
|
||||
}
|
||||
|
||||
if (platformOutput) {
|
||||
result.platformOutput = {
|
||||
address: platformOutput.scriptpubkey_address,
|
||||
amount: platformOutput.value,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error('Error verifying sponsoring transaction', {
|
||||
txid,
|
||||
authorAddress: authorMainnetAddress,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return {
|
||||
valid: false,
|
||||
confirmed: false,
|
||||
confirmations: 0,
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current block height and calculate confirmations
|
||||
*/
|
||||
private async getConfirmations(blockHeight: number): Promise<number> {
|
||||
try {
|
||||
const response = await fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`)
|
||||
if (!response.ok) {
|
||||
return 0
|
||||
}
|
||||
const currentHeight = await response.json() as number
|
||||
return Math.max(0, currentHeight - blockHeight + 1)
|
||||
} catch (error) {
|
||||
console.error('Error getting current block height', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
return 0
|
||||
}
|
||||
return await verifySponsoringTransaction(txid, authorMainnetAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -191,42 +41,7 @@ export class MempoolSpaceService {
|
||||
timeout: number = 600000, // 10 minutes
|
||||
interval: number = 10000 // 10 seconds
|
||||
): Promise<TransactionVerificationResult | null> {
|
||||
const startTime = Date.now()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkConfirmation = async () => {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Get author address from transaction (first output that's not platform)
|
||||
const transaction = await this.getTransaction(txid)
|
||||
if (!transaction) {
|
||||
setTimeout(checkConfirmation, interval)
|
||||
return
|
||||
}
|
||||
|
||||
const authorOutput = transaction.vout.find(
|
||||
(output) => output.scriptpubkey_address !== PLATFORM_BITCOIN_ADDRESS
|
||||
)
|
||||
|
||||
if (!authorOutput) {
|
||||
setTimeout(checkConfirmation, interval)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.verifySponsoringTransaction(txid, authorOutput.scriptpubkey_address)
|
||||
|
||||
if (result.confirmed && result.valid) {
|
||||
resolve(result)
|
||||
} else {
|
||||
setTimeout(checkConfirmation, interval)
|
||||
}
|
||||
}
|
||||
|
||||
checkConfirmation()
|
||||
})
|
||||
return await waitForConfirmation(txid, timeout, interval)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
lib/mempoolSpaceApi.ts
Normal file
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 { getPrimaryNip95Api } from './config'
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
const MAX_VIDEO_BYTES = 45 * 1024 * 1024
|
||||
@ -36,10 +37,10 @@ export async function uploadNip95Media(file: File): Promise<MediaRef> {
|
||||
assertBrowser()
|
||||
const mediaType = validateFile(file)
|
||||
|
||||
const endpoint = process.env.NEXT_PUBLIC_NIP95_UPLOAD_URL
|
||||
const endpoint = await getPrimaryNip95Api()
|
||||
if (!endpoint) {
|
||||
throw new Error(
|
||||
'NIP-95 upload endpoint is not configured. Please set NEXT_PUBLIC_NIP95_UPLOAD_URL environment variable. See README.md for setup instructions.'
|
||||
'NIP-95 upload endpoint is not configured. Please configure a NIP-95 API endpoint in the application settings.'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
63
lib/nostr.ts
63
lib/nostr.ts
@ -9,8 +9,8 @@ import {
|
||||
} from './nostrPrivateMessages'
|
||||
import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification'
|
||||
import { subscribeWithTimeout } from './nostrSubscription'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
import { getPrimaryRelay, getPrimaryRelaySync } from './config'
|
||||
import { buildTagFilter } from './nostrTagSystem'
|
||||
|
||||
class NostrService {
|
||||
private pool: SimplePool | null = null
|
||||
@ -77,7 +77,8 @@ class NostrService {
|
||||
} as Event
|
||||
|
||||
try {
|
||||
const pubs = this.pool.publish([RELAY_URL], event)
|
||||
const relayUrl = await getPrimaryRelay()
|
||||
const pubs = this.pool.publish([relayUrl], event)
|
||||
await Promise.all(pubs)
|
||||
return event
|
||||
} catch (e) {
|
||||
@ -85,6 +86,19 @@ class NostrService {
|
||||
}
|
||||
}
|
||||
|
||||
private createArticleSubscription(pool: SimplePoolWithSub, limit: number) {
|
||||
const filters = [
|
||||
{
|
||||
...buildTagFilter({
|
||||
type: 'publication',
|
||||
}),
|
||||
limit,
|
||||
},
|
||||
]
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
return pool.sub([relayUrl], filters)
|
||||
}
|
||||
|
||||
subscribeToArticles(callback: (article: Article) => void, limit: number = 100): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Cannot subscribe on server side')
|
||||
@ -98,20 +112,8 @@ class NostrService {
|
||||
throw new Error('Pool not initialized')
|
||||
}
|
||||
|
||||
// Use new tag system to filter publications
|
||||
// Import synchronously since this is not async
|
||||
const { buildTagFilter } = require('./nostrTagSystem')
|
||||
const filters = [
|
||||
{
|
||||
...buildTagFilter({
|
||||
type: 'publication',
|
||||
}),
|
||||
limit,
|
||||
},
|
||||
]
|
||||
|
||||
const pool = this.pool as SimplePoolWithSub
|
||||
const sub = pool.sub([RELAY_URL], filters)
|
||||
const sub = this.createArticleSubscription(pool, limit)
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
try {
|
||||
@ -152,39 +154,32 @@ class NostrService {
|
||||
* then retrieves the decryption key from private messages,
|
||||
* and finally decrypts the content
|
||||
*/
|
||||
private async retrieveDecryptionKey(eventId: string, authorPubkey: string): Promise<{ key: string; iv: string } | null> {
|
||||
if (!this.privateKey || !this.pool || !this.publicKey) {
|
||||
return null
|
||||
}
|
||||
return await getDecryptionKey(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey)
|
||||
}
|
||||
|
||||
async getDecryptedArticleContent(eventId: string, authorPubkey: string): Promise<string | null> {
|
||||
if (!this.privateKey || !this.pool || !this.publicKey) {
|
||||
throw new Error('Private key not set or pool not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the raw event to retrieve the encrypted content
|
||||
const event = await this.getEventById(eventId)
|
||||
if (!event) {
|
||||
console.error('Event not found', { eventId, authorPubkey })
|
||||
return null
|
||||
}
|
||||
|
||||
const encryptedContent = event.content
|
||||
|
||||
// Try to get the decryption key from private messages
|
||||
const decryptionKey = await getDecryptionKey(
|
||||
this.pool,
|
||||
eventId,
|
||||
authorPubkey,
|
||||
this.privateKey,
|
||||
this.publicKey
|
||||
)
|
||||
|
||||
const decryptionKey = await this.retrieveDecryptionKey(eventId, authorPubkey)
|
||||
if (!decryptionKey) {
|
||||
console.warn('Decryption key not found in private messages', { eventId, authorPubkey })
|
||||
return null
|
||||
}
|
||||
|
||||
// Decrypt the content using the key
|
||||
const decryptedContent = await decryptArticleContentWithKey(encryptedContent, decryptionKey)
|
||||
|
||||
return decryptedContent
|
||||
return await decryptArticleContentWithKey(event.content, decryptionKey)
|
||||
} catch (error) {
|
||||
console.error('Error decrypting article content', {
|
||||
eventId,
|
||||
@ -239,6 +234,8 @@ class NostrService {
|
||||
throw new Error('Private key not set')
|
||||
}
|
||||
|
||||
const relayUrl = await getPrimaryRelay()
|
||||
|
||||
const zapRequest: EventTemplate = {
|
||||
kind: 9734, // Zap request
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
@ -246,7 +243,7 @@ class NostrService {
|
||||
['p', targetPubkey],
|
||||
['e', targetEventId],
|
||||
['amount', amount.toString()],
|
||||
['relays', RELAY_URL],
|
||||
['relays', relayUrl],
|
||||
],
|
||||
content: '',
|
||||
}
|
||||
|
||||
124
lib/nostrAuth.ts
124
lib/nostrAuth.ts
@ -1,9 +1,10 @@
|
||||
import { nostrService } from './nostr'
|
||||
import { keyManagementService } from './keyManagement'
|
||||
import type { NostrConnectState } from '@/types/nostr'
|
||||
|
||||
/**
|
||||
* Nostr authentication service using Alby (NIP-07)
|
||||
* Alby exposes window.nostr API for Nostr authentication and signing
|
||||
* Nostr authentication service using local key management
|
||||
* Keys are stored encrypted in IndexedDB and decrypted using recovery phrase
|
||||
*/
|
||||
export class NostrAuthService {
|
||||
private state: NostrConnectState = {
|
||||
@ -13,6 +14,7 @@ export class NostrAuthService {
|
||||
}
|
||||
|
||||
private listeners: Set<(state: NostrConnectState) => void> = new Set()
|
||||
private unlockedPrivateKey: string | null = null
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
@ -34,55 +36,128 @@ export class NostrAuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Alby (window.nostr) is available
|
||||
* Check if account exists
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'
|
||||
async accountExists(): Promise<boolean> {
|
||||
return keyManagementService.accountExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect using Alby (NIP-07)
|
||||
* Create a new account (generate or import key)
|
||||
* Returns recovery phrase and npub
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Alby extension not available. Please install Alby browser extension.')
|
||||
}
|
||||
async createAccount(privateKey?: string): Promise<{
|
||||
recoveryPhrase: string[]
|
||||
npub: string
|
||||
publicKey: string
|
||||
}> {
|
||||
const result = await keyManagementService.createAccount(privateKey)
|
||||
|
||||
if (!window.nostr) {
|
||||
throw new Error('window.nostr is not available. Please ensure Alby extension is installed and enabled.')
|
||||
// Set public key immediately
|
||||
this.state = {
|
||||
connected: false,
|
||||
pubkey: result.publicKey,
|
||||
profile: null,
|
||||
}
|
||||
nostrService.setPublicKey(result.publicKey)
|
||||
this.saveStateToStorage()
|
||||
this.notifyListeners()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock account using recovery phrase
|
||||
*/
|
||||
async unlockAccount(recoveryPhrase: string[]): Promise<void> {
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
if (!pubkey) {
|
||||
throw new Error('Failed to get public key from Alby')
|
||||
}
|
||||
const keys = await keyManagementService.unlockAccount(recoveryPhrase)
|
||||
|
||||
this.unlockedPrivateKey = keys.privateKey
|
||||
this.state = {
|
||||
connected: true,
|
||||
pubkey,
|
||||
pubkey: keys.publicKey,
|
||||
profile: null,
|
||||
}
|
||||
nostrService.setPublicKey(pubkey)
|
||||
|
||||
nostrService.setPublicKey(keys.publicKey)
|
||||
nostrService.setPrivateKey(keys.privateKey)
|
||||
this.saveStateToStorage()
|
||||
this.notifyListeners()
|
||||
void this.loadProfile()
|
||||
} catch (e) {
|
||||
console.error('Error connecting with Alby:', e)
|
||||
throw new Error(`Failed to connect with Alby: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||
console.error('Error unlocking account:', e)
|
||||
throw new Error(`Failed to unlock account: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect using existing stored keys (if already unlocked)
|
||||
* This is called when the app loads and keys are already available
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
// Check if account exists
|
||||
const exists = await keyManagementService.accountExists()
|
||||
if (!exists) {
|
||||
throw new Error('No account found. Please create an account first.')
|
||||
}
|
||||
|
||||
// Try to get public keys
|
||||
const publicKeys = await keyManagementService.getPublicKeys()
|
||||
if (!publicKeys) {
|
||||
throw new Error('Account exists but public keys not found')
|
||||
}
|
||||
|
||||
// Set public key but don't unlock private key yet
|
||||
// Private key will be unlocked when user provides recovery phrase
|
||||
this.state = {
|
||||
connected: false,
|
||||
pubkey: publicKeys.publicKey,
|
||||
profile: null,
|
||||
}
|
||||
nostrService.setPublicKey(publicKeys.publicKey)
|
||||
this.saveStateToStorage()
|
||||
this.notifyListeners()
|
||||
void this.loadProfile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key if unlocked
|
||||
*/
|
||||
getPrivateKey(): string | null {
|
||||
return this.unlockedPrivateKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if private key is unlocked
|
||||
*/
|
||||
isUnlocked(): boolean {
|
||||
return this.unlockedPrivateKey !== null
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.unlockedPrivateKey = null
|
||||
this.state = {
|
||||
connected: false,
|
||||
pubkey: null,
|
||||
profile: null,
|
||||
}
|
||||
// Clear keys from nostrService - the service stores keys internally, we just clear our reference
|
||||
// The service will continue to work but won't have access to the keys
|
||||
nostrService.setPrivateKey('')
|
||||
nostrService.setPublicKey('')
|
||||
this.saveStateToStorage()
|
||||
this.notifyListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete account (remove all stored keys)
|
||||
*/
|
||||
async deleteAccount(): Promise<void> {
|
||||
await keyManagementService.deleteAccount()
|
||||
this.disconnect()
|
||||
}
|
||||
|
||||
private async loadProfile(): Promise<void> {
|
||||
if (!this.state.pubkey) {
|
||||
return
|
||||
@ -121,6 +196,7 @@ export class NostrAuthService {
|
||||
if (this.state.pubkey) {
|
||||
nostrService.setPublicKey(this.state.pubkey)
|
||||
}
|
||||
// Note: private key is not stored, it must be unlocked with recovery phrase
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading state from storage:', e)
|
||||
@ -129,7 +205,13 @@ export class NostrAuthService {
|
||||
|
||||
private saveStateToStorage(): void {
|
||||
try {
|
||||
localStorage.setItem('nostr_auth_state', JSON.stringify(this.state))
|
||||
// Only save public information, never private keys
|
||||
const stateToSave = {
|
||||
connected: this.state.connected,
|
||||
pubkey: this.state.pubkey,
|
||||
profile: this.state.profile,
|
||||
}
|
||||
localStorage.setItem('nostr_auth_state', JSON.stringify(stateToSave))
|
||||
} catch (e) {
|
||||
console.error('Error saving state to storage:', e)
|
||||
}
|
||||
|
||||
@ -99,20 +99,20 @@ function buildArticle(event: Event, tags: ReturnType<typeof extractTagsFromEvent
|
||||
return {
|
||||
id: tags.id ?? event.id,
|
||||
pubkey: event.pubkey,
|
||||
title: (tags.title as string | undefined) ?? 'Untitled',
|
||||
title: tags.title ?? 'Untitled',
|
||||
preview,
|
||||
content: '',
|
||||
createdAt: event.created_at,
|
||||
zapAmount: (tags.zapAmount as number | undefined) ?? 800,
|
||||
zapAmount: tags.zapAmount ?? 800,
|
||||
paid: false,
|
||||
...(tags.invoice ? { invoice: tags.invoice as string } : {}),
|
||||
...(tags.paymentHash ? { paymentHash: tags.paymentHash as string } : {}),
|
||||
...(tags.invoice ? { invoice: tags.invoice } : {}),
|
||||
...(tags.paymentHash ? { paymentHash: tags.paymentHash } : {}),
|
||||
...(category ? { category } : {}),
|
||||
...(isPresentation ? { isPresentation: true } : {}),
|
||||
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress as string } : {}),
|
||||
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring as number } : {}),
|
||||
...(tags.seriesId ? { seriesId: tags.seriesId as string } : {}),
|
||||
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl as string } : {}),
|
||||
...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress } : {}),
|
||||
...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring } : {}),
|
||||
...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
|
||||
...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
|
||||
...(tags.type === 'publication' ? { kindType: 'article' as KindType } : tags.type === 'author' ? { kindType: 'article' as KindType } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { Event, nip04 } from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import { decryptArticleContent, type DecryptionKey } from './articleEncryption'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
|
||||
function createPrivateMessageFilters(eventId: string, publicKey: string, authorPubkey: string) {
|
||||
return [
|
||||
@ -39,7 +38,8 @@ export function getPrivateContent(
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
const sub = pool.sub([RELAY_URL], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const sub = pool.sub([relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
|
||||
|
||||
const finalize = (result: string | null) => {
|
||||
if (resolved) {
|
||||
@ -70,6 +70,37 @@ export function getPrivateContent(
|
||||
* Get decryption key for an article from private messages
|
||||
* Returns the decryption key and IV if found
|
||||
*/
|
||||
function parseDecryptionKey(decryptedContent: string): DecryptionKey | null {
|
||||
try {
|
||||
const keyData = JSON.parse(decryptedContent) as DecryptionKey
|
||||
if (keyData.key && keyData.iv) {
|
||||
return keyData
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, it might be old format (full content)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handleDecryptionKeyEvent(
|
||||
event: Event,
|
||||
recipientPrivateKey: string,
|
||||
finalize: (result: DecryptionKey | null) => void
|
||||
): void {
|
||||
void decryptContent(recipientPrivateKey, event)
|
||||
.then((decryptedContent) => {
|
||||
if (decryptedContent) {
|
||||
const keyData = parseDecryptionKey(decryptedContent)
|
||||
if (keyData) {
|
||||
finalize(keyData)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error decrypting decryption key:', e)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getDecryptionKey(
|
||||
pool: SimplePool,
|
||||
eventId: string,
|
||||
@ -83,7 +114,8 @@ export async function getDecryptionKey(
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
const sub = pool.sub([RELAY_URL], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const sub = pool.sub([relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
|
||||
|
||||
const finalize = (result: DecryptionKey | null) => {
|
||||
if (resolved) {
|
||||
@ -94,25 +126,8 @@ export async function getDecryptionKey(
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
sub.on('event', async (event: Event) => {
|
||||
try {
|
||||
const decryptedContent = await decryptContent(recipientPrivateKey, event)
|
||||
if (decryptedContent) {
|
||||
try {
|
||||
// Try to parse as decryption key (new format)
|
||||
const keyData = JSON.parse(decryptedContent) as DecryptionKey
|
||||
if (keyData.key && keyData.iv) {
|
||||
finalize(keyData)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, it might be old format (full content)
|
||||
// Return null to indicate we need to use the old method
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error decrypting decryption key:', e)
|
||||
}
|
||||
sub.on('event', (event: Event) => {
|
||||
handleDecryptionKeyEvent(event, recipientPrivateKey, finalize)
|
||||
})
|
||||
sub.on('eose', () => finalize(null))
|
||||
setTimeout(() => finalize(null), 5000)
|
||||
|
||||
@ -11,52 +11,58 @@ export class NostrRemoteSigner {
|
||||
/**
|
||||
* Sign an event template using Alby (window.nostr)
|
||||
*/
|
||||
private buildUnsignedEvent(eventTemplate: EventTemplate, pubkey: string): EventTemplate & { pubkey: string } {
|
||||
return {
|
||||
pubkey,
|
||||
...eventTemplate,
|
||||
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
private async signWithAlby(unsignedEvent: EventTemplate & { pubkey: string }): Promise<Event | null> {
|
||||
if (typeof window === 'undefined' || !window.nostr) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent({
|
||||
kind: unsignedEvent.kind,
|
||||
created_at: unsignedEvent.created_at,
|
||||
tags: unsignedEvent.tags,
|
||||
content: unsignedEvent.content,
|
||||
})
|
||||
return signedEvent as Event
|
||||
} catch (e) {
|
||||
console.error('Error signing with Alby:', e)
|
||||
throw new Error('Failed to sign event with Alby extension')
|
||||
}
|
||||
}
|
||||
|
||||
private signWithPrivateKey(unsignedEvent: EventTemplate & { pubkey: string }): Event {
|
||||
const privateKey = nostrService.getPrivateKey()
|
||||
if (!privateKey) {
|
||||
throw new Error('Alby extension required for signing. Please install and connect Alby browser extension.')
|
||||
}
|
||||
const eventId = getEventHash(unsignedEvent)
|
||||
return {
|
||||
...unsignedEvent,
|
||||
id: eventId,
|
||||
sig: signEvent(unsignedEvent, privateKey),
|
||||
} as Event
|
||||
}
|
||||
|
||||
async signEvent(eventTemplate: EventTemplate): Promise<Event | null> {
|
||||
// Get the event hash first
|
||||
const pubkey = nostrService.getPublicKey()
|
||||
if (!pubkey) {
|
||||
throw new Error('Public key required for signing. Please connect with Alby.')
|
||||
}
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey,
|
||||
...eventTemplate,
|
||||
created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
|
||||
}
|
||||
const eventId = getEventHash(unsignedEvent)
|
||||
|
||||
// Use Alby (window.nostr) for signing
|
||||
if (typeof window !== 'undefined' && window.nostr) {
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent({
|
||||
kind: unsignedEvent.kind,
|
||||
created_at: unsignedEvent.created_at,
|
||||
tags: unsignedEvent.tags,
|
||||
content: unsignedEvent.content,
|
||||
})
|
||||
return signedEvent as Event
|
||||
} catch (e) {
|
||||
console.error('Error signing with Alby:', e)
|
||||
throw new Error('Failed to sign event with Alby extension')
|
||||
}
|
||||
const unsignedEvent = this.buildUnsignedEvent(eventTemplate, pubkey)
|
||||
const albySigned = await this.signWithAlby(unsignedEvent)
|
||||
if (albySigned) {
|
||||
return albySigned
|
||||
}
|
||||
|
||||
// Fallback to private key signing (should not happen if Alby is properly connected)
|
||||
const privateKey = nostrService.getPrivateKey()
|
||||
if (!privateKey) {
|
||||
throw new Error(
|
||||
'Alby extension required for signing. ' +
|
||||
'Please install and connect Alby browser extension.'
|
||||
)
|
||||
}
|
||||
|
||||
const event = {
|
||||
...unsignedEvent,
|
||||
id: eventId,
|
||||
sig: signEvent(unsignedEvent, privateKey),
|
||||
} as Event
|
||||
|
||||
return event
|
||||
return this.signWithPrivateKey(unsignedEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { Event, Filter } from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
|
||||
/**
|
||||
* Subscribe to events with timeout
|
||||
@ -14,7 +13,8 @@ export function subscribeWithTimeout<T>(
|
||||
): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
const resolved = { value: false }
|
||||
const sub = pool.sub([RELAY_URL], filters)
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const sub = pool.sub([relayUrl], filters)
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
|
||||
@ -1,274 +1,4 @@
|
||||
/**
|
||||
* New tag system based on:
|
||||
* - #paywall: for paid publications
|
||||
* - #sciencefiction or #research: for category
|
||||
* - #author, #series, #publication, #quote: for type
|
||||
* - #id_<id>: for identifier
|
||||
* - #payment (optional): for payment notes
|
||||
*
|
||||
* Everything is a Nostr note (kind 1)
|
||||
* All tags are in English
|
||||
*/
|
||||
|
||||
export type TagType = 'author' | 'series' | 'publication' | 'quote'
|
||||
export type TagCategory = 'sciencefiction' | 'research'
|
||||
|
||||
export interface BaseTags {
|
||||
type: TagType
|
||||
category: TagCategory
|
||||
id: string
|
||||
paywall?: boolean
|
||||
payment?: boolean
|
||||
}
|
||||
|
||||
export interface AuthorTags extends BaseTags {
|
||||
type: 'author'
|
||||
title: string
|
||||
preview?: string
|
||||
mainnetAddress?: string
|
||||
totalSponsoring?: number
|
||||
pictureUrl?: string
|
||||
}
|
||||
|
||||
export interface SeriesTags extends BaseTags {
|
||||
type: 'series'
|
||||
title: string
|
||||
description: string
|
||||
preview?: string
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
export interface PublicationTags extends BaseTags {
|
||||
type: 'publication'
|
||||
title: string
|
||||
preview?: string
|
||||
seriesId?: string
|
||||
bannerUrl?: string
|
||||
zapAmount?: number
|
||||
invoice?: string
|
||||
paymentHash?: string
|
||||
encryptedKey?: string
|
||||
}
|
||||
|
||||
export interface QuoteTags extends BaseTags {
|
||||
type: 'quote'
|
||||
articleId: string
|
||||
reviewerPubkey?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tags array from tag object
|
||||
* Tags format: ['tag_name'] for simple tags, ['tag_name', 'value'] for tags with values
|
||||
*/
|
||||
export function buildTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTags): string[][] {
|
||||
const result: string[][] = []
|
||||
|
||||
// Type tag (required) - simple tag without value
|
||||
result.push([tags.type])
|
||||
|
||||
// Category tag (required) - simple tag without value
|
||||
result.push([tags.category])
|
||||
|
||||
// ID tag (required) - tag with value: ['id', '<id>']
|
||||
result.push(['id', tags.id])
|
||||
|
||||
// Paywall tag (optional) - simple tag without value
|
||||
if (tags.paywall) {
|
||||
result.push(['paywall'])
|
||||
}
|
||||
|
||||
// Payment tag (optional) - simple tag without value
|
||||
if (tags.payment) {
|
||||
result.push(['payment'])
|
||||
}
|
||||
|
||||
// Type-specific tags
|
||||
if (tags.type === 'author') {
|
||||
const authorTags = tags as AuthorTags
|
||||
result.push(['title', authorTags.title])
|
||||
if (authorTags.preview) {
|
||||
result.push(['preview', authorTags.preview])
|
||||
}
|
||||
if (authorTags.mainnetAddress) {
|
||||
result.push(['mainnet_address', authorTags.mainnetAddress])
|
||||
}
|
||||
if (authorTags.totalSponsoring !== undefined) {
|
||||
result.push(['total_sponsoring', authorTags.totalSponsoring.toString()])
|
||||
}
|
||||
if (authorTags.pictureUrl) {
|
||||
result.push(['picture', authorTags.pictureUrl])
|
||||
}
|
||||
} else if (tags.type === 'series') {
|
||||
const seriesTags = tags as SeriesTags
|
||||
result.push(['title', seriesTags.title])
|
||||
result.push(['description', seriesTags.description])
|
||||
if (seriesTags.preview) {
|
||||
result.push(['preview', seriesTags.preview])
|
||||
}
|
||||
if (seriesTags.coverUrl) {
|
||||
result.push(['cover', seriesTags.coverUrl])
|
||||
}
|
||||
} else if (tags.type === 'publication') {
|
||||
const pubTags = tags as PublicationTags
|
||||
result.push(['title', pubTags.title])
|
||||
if (pubTags.preview) {
|
||||
result.push(['preview', pubTags.preview])
|
||||
}
|
||||
if (pubTags.seriesId) {
|
||||
result.push(['series', pubTags.seriesId])
|
||||
}
|
||||
if (pubTags.bannerUrl) {
|
||||
result.push(['banner', pubTags.bannerUrl])
|
||||
}
|
||||
if (pubTags.zapAmount) {
|
||||
result.push(['zap', pubTags.zapAmount.toString()])
|
||||
}
|
||||
if (pubTags.invoice) {
|
||||
result.push(['invoice', pubTags.invoice])
|
||||
}
|
||||
if (pubTags.paymentHash) {
|
||||
result.push(['payment_hash', pubTags.paymentHash])
|
||||
}
|
||||
if (pubTags.encryptedKey) {
|
||||
result.push(['encrypted_key', pubTags.encryptedKey])
|
||||
}
|
||||
} else if (tags.type === 'quote') {
|
||||
const quoteTags = tags as QuoteTags
|
||||
result.push(['article', quoteTags.articleId])
|
||||
if (quoteTags.reviewerPubkey) {
|
||||
result.push(['reviewer', quoteTags.reviewerPubkey])
|
||||
}
|
||||
if (quoteTags.title) {
|
||||
result.push(['title', quoteTags.title])
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tags from event
|
||||
*/
|
||||
export function extractTagsFromEvent(event: { tags: string[][] }): {
|
||||
type?: TagType | undefined
|
||||
category?: TagCategory | undefined
|
||||
id?: string | undefined
|
||||
paywall: boolean
|
||||
payment: boolean
|
||||
title?: string | undefined
|
||||
preview?: string | undefined
|
||||
description?: string | undefined
|
||||
mainnetAddress?: string | undefined
|
||||
totalSponsoring?: number | undefined
|
||||
seriesId?: string | undefined
|
||||
coverUrl?: string | undefined
|
||||
bannerUrl?: string | undefined
|
||||
zapAmount?: number | undefined
|
||||
invoice?: string | undefined
|
||||
paymentHash?: string | undefined
|
||||
articleId?: string | undefined
|
||||
reviewerPubkey?: string | undefined
|
||||
[key: string]: unknown
|
||||
} {
|
||||
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
|
||||
const hasTag = (key: string) => event.tags.some((tag) => tag[0] === key || (tag.length === 1 && tag[0] === key))
|
||||
|
||||
const type = event.tags.find((tag) => tag.length === 1 && tag[0] && ['author', 'series', 'publication', 'quote'].includes(tag[0]))?.[0] as TagType | undefined
|
||||
const category = event.tags.find((tag) => tag.length === 1 && tag[0] && ['sciencefiction', 'research'].includes(tag[0]))?.[0] as TagCategory | undefined
|
||||
const id = findTag('id')
|
||||
|
||||
return {
|
||||
type,
|
||||
category,
|
||||
id,
|
||||
paywall: hasTag('paywall'),
|
||||
payment: hasTag('payment'),
|
||||
// Extract all other tags
|
||||
title: findTag('title'),
|
||||
preview: findTag('preview'),
|
||||
description: findTag('description'),
|
||||
mainnetAddress: findTag('mainnet_address'),
|
||||
totalSponsoring: (() => {
|
||||
const val = findTag('total_sponsoring')
|
||||
return val ? parseInt(val, 10) : undefined
|
||||
})(),
|
||||
pictureUrl: findTag('picture'),
|
||||
seriesId: findTag('series'),
|
||||
coverUrl: findTag('cover'),
|
||||
bannerUrl: findTag('banner'),
|
||||
zapAmount: (() => {
|
||||
const val = findTag('zap')
|
||||
return val ? parseInt(val, 10) : undefined
|
||||
})(),
|
||||
invoice: findTag('invoice'),
|
||||
paymentHash: findTag('payment_hash'),
|
||||
encryptedKey: findTag('encrypted_key'),
|
||||
articleId: findTag('article'),
|
||||
reviewerPubkey: findTag('reviewer'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Nostr filter for querying by tags
|
||||
* Nostr filters use #tag for tag-based filtering
|
||||
*/
|
||||
export function buildTagFilter(params: {
|
||||
type?: TagType
|
||||
category?: TagCategory
|
||||
id?: string
|
||||
paywall?: boolean
|
||||
payment?: boolean
|
||||
seriesId?: string
|
||||
articleId?: string
|
||||
authorPubkey?: string
|
||||
}): Record<string, string[] | number[]> {
|
||||
const filter: Record<string, string[] | number[]> = {
|
||||
kinds: [1], // All are kind 1 notes
|
||||
}
|
||||
|
||||
// Type tag filter (simple tag without value)
|
||||
if (params.type) {
|
||||
filter[`#${params.type}`] = ['']
|
||||
}
|
||||
|
||||
// Category tag filter (simple tag without value)
|
||||
if (params.category) {
|
||||
filter[`#${params.category}`] = ['']
|
||||
}
|
||||
|
||||
// ID tag filter (tag with value)
|
||||
if (params.id) {
|
||||
filter['#id'] = [params.id]
|
||||
} else {
|
||||
// If no ID specified, we still need to ensure the filter structure is valid
|
||||
// Nostr filters require at least one valid filter property
|
||||
}
|
||||
|
||||
// Paywall tag filter (simple tag without value)
|
||||
if (params.paywall) {
|
||||
filter['#paywall'] = ['']
|
||||
}
|
||||
|
||||
// Payment tag filter (simple tag without value)
|
||||
if (params.payment) {
|
||||
filter['#payment'] = ['']
|
||||
}
|
||||
|
||||
// Series ID filter (tag with value)
|
||||
if (params.seriesId) {
|
||||
filter['#series'] = [params.seriesId]
|
||||
}
|
||||
|
||||
// Article ID filter (tag with value)
|
||||
if (params.articleId) {
|
||||
filter['#article'] = [params.articleId]
|
||||
}
|
||||
|
||||
// Author pubkey filter
|
||||
if (params.authorPubkey) {
|
||||
filter.authors = [params.authorPubkey]
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
export type { TagType, TagCategory, BaseTags, AuthorTags, SeriesTags, PublicationTags, QuoteTags } from './nostrTagSystemTypes'
|
||||
export { buildTags } from './nostrTagSystemBuild'
|
||||
export { extractTagsFromEvent } from './nostrTagSystemExtract'
|
||||
export { buildTagFilter } from './nostrTagSystemFilter'
|
||||
|
||||
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 { SimplePool } from 'nostr-tools'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
|
||||
function createZapFilters(targetPubkey: string, targetEventId: string, userPubkey: string) {
|
||||
return [
|
||||
@ -30,6 +29,25 @@ async function isValidZapReceipt(
|
||||
/**
|
||||
* Check if user has paid for an article by looking for zap receipts
|
||||
*/
|
||||
function handleZapReceiptEvent(
|
||||
event: Event,
|
||||
targetEventId: string,
|
||||
targetPubkey: string,
|
||||
userPubkey: string,
|
||||
amount: number,
|
||||
finalize: (value: boolean) => void,
|
||||
resolved: { current: boolean }
|
||||
): void {
|
||||
if (resolved.current) {
|
||||
return
|
||||
}
|
||||
void isValidZapReceipt(event, targetEventId, targetPubkey, userPubkey, amount).then((isValid) => {
|
||||
if (isValid) {
|
||||
finalize(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function checkZapReceipt(
|
||||
pool: SimplePool,
|
||||
targetPubkey: string,
|
||||
@ -43,7 +61,8 @@ export function checkZapReceipt(
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
const sub = pool.sub([RELAY_URL], createZapFilters(targetPubkey, targetEventId, userPubkey))
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const sub = pool.sub([relayUrl], createZapFilters(targetPubkey, targetEventId, userPubkey))
|
||||
|
||||
const finalize = (value: boolean) => {
|
||||
if (resolved) {
|
||||
@ -54,15 +73,9 @@ export function checkZapReceipt(
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const resolvedRef = { current: resolved }
|
||||
sub.on('event', (event: Event) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
void isValidZapReceipt(event, targetEventId, targetPubkey, userPubkey, amount).then((isValid) => {
|
||||
if (isValid) {
|
||||
finalize(true)
|
||||
}
|
||||
})
|
||||
handleZapReceiptEvent(event, targetEventId, targetPubkey, userPubkey, amount, finalize, resolvedRef)
|
||||
})
|
||||
|
||||
const end = () => finalize(false)
|
||||
|
||||
@ -3,8 +3,7 @@ import { nostrService } from './nostr'
|
||||
import { zapVerificationService } from './zapVerification'
|
||||
import type { Notification } from '@/types/notifications'
|
||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
import { getPrimaryRelaySync } from './config'
|
||||
|
||||
function createZapReceiptFilters(userPubkey: string) {
|
||||
return [
|
||||
@ -86,7 +85,8 @@ export class NotificationService {
|
||||
|
||||
const filters = createZapReceiptFilters(userPubkey)
|
||||
const poolWithSub = pool as SimplePoolWithSub
|
||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const sub = poolWithSub.sub([relayUrl], filters)
|
||||
|
||||
registerZapSubscription(sub, userPubkey, onNotification)
|
||||
|
||||
|
||||
@ -25,34 +25,42 @@ export class PaymentService {
|
||||
* Create a Lightning invoice for an article payment
|
||||
* First checks if author has created an invoice in the event tags, otherwise creates a new one
|
||||
*/
|
||||
private validateArticleAmount(zapAmount: number): { valid: boolean; error?: string } {
|
||||
const expectedAmount = PLATFORM_COMMISSIONS.article.total
|
||||
if (zapAmount !== expectedAmount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid article payment amount: ${zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
private validateInvoiceAmount(invoice: AlbyInvoice): { valid: boolean; error?: string } {
|
||||
const split = calculateArticleSplit()
|
||||
if (invoice.amount !== split.total) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invoice amount mismatch: ${invoice.amount} sats. Expected ${split.total} sats (${split.author} to author, ${split.platform} commission)`,
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
async createArticlePayment(request: PaymentRequest): Promise<PaymentResult> {
|
||||
try {
|
||||
// Verify article amount matches expected commission structure
|
||||
const expectedAmount = PLATFORM_COMMISSIONS.article.total
|
||||
if (request.article.zapAmount !== expectedAmount) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid article payment amount: ${request.article.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
|
||||
}
|
||||
const amountValidation = this.validateArticleAmount(request.article.zapAmount)
|
||||
if (!amountValidation.valid) {
|
||||
return { success: false, error: amountValidation.error ?? 'Invalid amount' }
|
||||
}
|
||||
|
||||
const invoice = await resolveArticleInvoice(request.article)
|
||||
|
||||
// Verify invoice amount matches expected commission structure
|
||||
const split = calculateArticleSplit()
|
||||
if (invoice.amount !== split.total) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invoice amount mismatch: ${invoice.amount} sats. Expected ${split.total} sats (${split.author} to author, ${split.platform} commission)`,
|
||||
}
|
||||
const invoiceValidation = this.validateInvoiceAmount(invoice)
|
||||
if (!invoiceValidation.valid) {
|
||||
return { success: false, error: invoiceValidation.error ?? 'Invalid invoice amount' }
|
||||
}
|
||||
|
||||
// Create zap request event on Nostr
|
||||
await nostrService.createZapRequest(
|
||||
request.article.pubkey,
|
||||
request.article.id,
|
||||
request.article.zapAmount
|
||||
)
|
||||
await nostrService.createZapRequest(request.article.pubkey, request.article.id, request.article.zapAmount)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@ -1,265 +1 @@
|
||||
import { nostrService } from './nostr'
|
||||
import { articlePublisher } from './articlePublisher'
|
||||
import { getStoredPrivateContent } from './articleStorage'
|
||||
import { platformTracking } from './platformTracking'
|
||||
import { calculateArticleSplit } from './platformCommissions'
|
||||
import { automaticTransferService } from './automaticTransfer'
|
||||
import { lightningAddressService } from './lightningAddress'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
|
||||
/**
|
||||
* Poll for payment completion via zap receipt verification
|
||||
* After payment is confirmed, sends private content to the user
|
||||
*/
|
||||
async function pollPaymentUntilDeadline(
|
||||
articleId: string,
|
||||
articlePubkey: string,
|
||||
amount: number,
|
||||
recipientPubkey: string,
|
||||
interval: number,
|
||||
deadline: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const zapReceiptExists = await nostrService.checkZapReceipt(articlePubkey, articleId, amount, recipientPubkey)
|
||||
if (zapReceiptExists) {
|
||||
const zapReceiptId = await getZapReceiptId(articlePubkey, articleId, amount, recipientPubkey)
|
||||
await sendPrivateContentAfterPayment(articleId, recipientPubkey, amount, zapReceiptId)
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking zap receipt:', error)
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
return false
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => {
|
||||
void pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
|
||||
.then(resolve)
|
||||
.catch(() => resolve(false))
|
||||
}, interval)
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForArticlePayment(
|
||||
_paymentHash: string,
|
||||
articleId: string,
|
||||
articlePubkey: string,
|
||||
amount: number,
|
||||
recipientPubkey: string,
|
||||
timeout: number = 300000 // 5 minutes
|
||||
): Promise<boolean> {
|
||||
const interval = 2000
|
||||
const deadline = Date.now() + timeout
|
||||
try {
|
||||
return await pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
|
||||
} catch (error) {
|
||||
console.error('Wait for payment error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getZapReceiptId(
|
||||
articlePubkey: string,
|
||||
articleId: string,
|
||||
amount: number,
|
||||
recipientPubkey: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const filters = [
|
||||
{
|
||||
kinds: [9735],
|
||||
'#p': [articlePubkey],
|
||||
'#e': [articleId],
|
||||
limit: 1,
|
||||
},
|
||||
]
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
|
||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
||||
|
||||
const finalize = (value: string | undefined) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
sub.unsub()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
sub.on('event', (event) => {
|
||||
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
|
||||
const amountInSats = amountTag ? Math.floor(parseInt(amountTag, 10) / 1000) : 0
|
||||
if (amountInSats === amount && event.pubkey === recipientPubkey) {
|
||||
finalize(event.id)
|
||||
}
|
||||
})
|
||||
|
||||
sub.on('eose', () => finalize(undefined))
|
||||
setTimeout(() => finalize(undefined), 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error getting zap receipt ID', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send private content to user after payment confirmation
|
||||
* Returns true if content was successfully sent and verified
|
||||
*/
|
||||
async function sendPrivateContentAfterPayment(
|
||||
articleId: string,
|
||||
recipientPubkey: string,
|
||||
amount: number,
|
||||
zapReceiptId?: string
|
||||
): Promise<boolean> {
|
||||
const storedContent = await getStoredPrivateContent(articleId)
|
||||
|
||||
if (!storedContent) {
|
||||
console.error('Stored private content not found for article', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const authorPrivateKey = nostrService.getPrivateKey()
|
||||
|
||||
if (!authorPrivateKey) {
|
||||
console.error('Author private key not available, cannot send private content automatically', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
authorPubkey: storedContent.authorPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, authorPrivateKey)
|
||||
|
||||
if (result.success && result.messageEventId) {
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
|
||||
// Verify payment amount matches expected commission structure
|
||||
const expectedSplit = calculateArticleSplit()
|
||||
if (amount !== expectedSplit.total) {
|
||||
console.error('Payment amount does not match expected commission structure', {
|
||||
articleId,
|
||||
paidAmount: amount,
|
||||
expectedTotal: expectedSplit.total,
|
||||
expectedAuthor: expectedSplit.author,
|
||||
expectedPlatform: expectedSplit.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Track content delivery with commission information
|
||||
const trackingData: import('./platformTracking').ContentDeliveryTracking = {
|
||||
articleId,
|
||||
articlePubkey: storedContent.authorPubkey,
|
||||
recipientPubkey,
|
||||
messageEventId: result.messageEventId,
|
||||
amount,
|
||||
authorAmount: expectedSplit.author,
|
||||
platformCommission: expectedSplit.platform,
|
||||
timestamp,
|
||||
verified: result.verified ?? false,
|
||||
}
|
||||
|
||||
if (zapReceiptId) {
|
||||
trackingData.zapReceiptId = zapReceiptId
|
||||
}
|
||||
|
||||
await platformTracking.trackContentDelivery(trackingData, authorPrivateKey)
|
||||
|
||||
// Log commission information for platform tracking
|
||||
console.log('Article payment processed with commission', {
|
||||
articleId,
|
||||
totalAmount: amount,
|
||||
authorPortion: expectedSplit.author,
|
||||
platformCommission: expectedSplit.platform,
|
||||
recipientPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Trigger automatic transfer of author portion
|
||||
try {
|
||||
// Get author's Lightning address from profile
|
||||
const authorLightningAddress = await lightningAddressService.getLightningAddress(storedContent.authorPubkey)
|
||||
|
||||
if (authorLightningAddress) {
|
||||
const transferResult = await automaticTransferService.transferAuthorPortion(
|
||||
authorLightningAddress,
|
||||
articleId,
|
||||
storedContent.authorPubkey,
|
||||
amount
|
||||
)
|
||||
|
||||
if (!transferResult.success) {
|
||||
console.warn('Automatic transfer failed, will be retried later', {
|
||||
articleId,
|
||||
authorPubkey: storedContent.authorPubkey,
|
||||
error: transferResult.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.warn('Author Lightning address not available for automatic transfer', {
|
||||
articleId,
|
||||
authorPubkey: storedContent.authorPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
// Transfer will need to be done manually later
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error triggering automatic transfer', {
|
||||
articleId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
// Don't fail the payment process if transfer fails
|
||||
}
|
||||
|
||||
if (result.verified) {
|
||||
console.log('Private content sent and verified on relay', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
messageEventId: result.messageEventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
console.warn('Private content sent but not yet verified on relay', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
messageEventId: result.messageEventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to send private content, but payment was confirmed', {
|
||||
articleId,
|
||||
recipientPubkey,
|
||||
error: result.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
export { waitForArticlePayment } from './paymentPollingCore'
|
||||
|
||||
57
lib/paymentPollingCore.ts
Normal file
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
|
||||
* 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
|
||||
|
||||
@ -1,9 +1,29 @@
|
||||
export const PLATFORM_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu'
|
||||
export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y'
|
||||
|
||||
import { getPlatformLightningAddress as getAddress, getPlatformLightningAddressSync as getAddressSync } from './config'
|
||||
|
||||
/**
|
||||
* Platform Lightning address for receiving commissions
|
||||
* This should be configured with the platform's Lightning node
|
||||
* Format: user@domain.com or LNURL
|
||||
*
|
||||
* @deprecated Use getPlatformLightningAddress() or getPlatformLightningAddressSync() instead
|
||||
*/
|
||||
export const PLATFORM_LIGHTNING_ADDRESS = process.env.NEXT_PUBLIC_PLATFORM_LIGHTNING_ADDRESS || ''
|
||||
export const PLATFORM_LIGHTNING_ADDRESS = ''
|
||||
|
||||
/**
|
||||
* Get platform Lightning address (async)
|
||||
* Uses IndexedDB if available, otherwise returns default
|
||||
*/
|
||||
export async function getPlatformLightningAddress(): Promise<string> {
|
||||
return getAddress()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform Lightning address (sync)
|
||||
* Returns default if IndexedDB is not ready
|
||||
*/
|
||||
export function getPlatformLightningAddressSync(): string {
|
||||
return getAddressSync()
|
||||
}
|
||||
|
||||
@ -1,22 +1,12 @@
|
||||
import { Event, EventTemplate, getEventHash, signEvent } from 'nostr-tools'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { nostrService } from './nostr'
|
||||
import { PLATFORM_NPUB } from './platformConfig'
|
||||
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
|
||||
import type { ContentDeliveryTracking } from './platformTrackingTypes'
|
||||
import { buildTrackingEvent } from './platformTrackingEvents'
|
||||
import { parseTrackingEvent, createArticleDeliveriesSubscription, createRecipientDeliveriesSubscription } from './platformTrackingQueries'
|
||||
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
|
||||
export interface ContentDeliveryTracking {
|
||||
articleId: string
|
||||
articlePubkey: string
|
||||
recipientPubkey: string
|
||||
messageEventId: string
|
||||
zapReceiptId?: string
|
||||
amount: number
|
||||
authorAmount?: number
|
||||
platformCommission?: number
|
||||
timestamp: number
|
||||
verified: boolean
|
||||
}
|
||||
export type { ContentDeliveryTracking } from './platformTrackingTypes'
|
||||
|
||||
/**
|
||||
* Platform tracking service
|
||||
@ -25,7 +15,34 @@ export interface ContentDeliveryTracking {
|
||||
*/
|
||||
export class PlatformTrackingService {
|
||||
private readonly platformPubkey: string = PLATFORM_NPUB
|
||||
private readonly trackingKind = 30078 // Custom kind for platform tracking
|
||||
|
||||
private async publishTrackingEvent(event: Event): Promise<void> {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
throw new Error('Pool not initialized')
|
||||
}
|
||||
const poolWithSub = pool as SimplePoolWithSub
|
||||
const { getPrimaryRelaySync } = await import('./config')
|
||||
const relayUrl = getPrimaryRelaySync()
|
||||
const pubs = poolWithSub.publish([relayUrl], event)
|
||||
await Promise.all(pubs)
|
||||
}
|
||||
|
||||
private validateTrackingPool(): { pool: SimplePoolWithSub; authorPubkey: string } | null {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
console.error('Pool not initialized for platform tracking')
|
||||
return null
|
||||
}
|
||||
|
||||
const authorPubkey = nostrService.getPublicKey()
|
||||
if (!authorPubkey) {
|
||||
console.error('Author public key not available for tracking')
|
||||
return null
|
||||
}
|
||||
|
||||
return { pool: pool as SimplePoolWithSub, authorPubkey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a content delivery tracking event
|
||||
@ -37,62 +54,14 @@ export class PlatformTrackingService {
|
||||
authorPrivateKey: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
console.error('Pool not initialized for platform tracking')
|
||||
const validation = this.validateTrackingPool()
|
||||
if (!validation) {
|
||||
return null
|
||||
}
|
||||
|
||||
const authorPubkey = nostrService.getPublicKey()
|
||||
if (!authorPubkey) {
|
||||
console.error('Author public key not available for tracking')
|
||||
return null
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: this.trackingKind,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['p', this.platformPubkey], // Tag platform for querying
|
||||
['article', tracking.articleId],
|
||||
['author', tracking.articlePubkey],
|
||||
['recipient', tracking.recipientPubkey],
|
||||
['message', tracking.messageEventId],
|
||||
['amount', tracking.amount.toString()],
|
||||
...(tracking.authorAmount ? [['author_amount', tracking.authorAmount.toString()]] : []),
|
||||
...(tracking.platformCommission ? [['platform_commission', tracking.platformCommission.toString()]] : []),
|
||||
['verified', tracking.verified ? 'true' : 'false'],
|
||||
['timestamp', tracking.timestamp.toString()],
|
||||
...(tracking.zapReceiptId ? [['zap_receipt', tracking.zapReceiptId]] : []),
|
||||
],
|
||||
content: JSON.stringify({
|
||||
articleId: tracking.articleId,
|
||||
articlePubkey: tracking.articlePubkey,
|
||||
recipientPubkey: tracking.recipientPubkey,
|
||||
messageEventId: tracking.messageEventId,
|
||||
amount: tracking.amount,
|
||||
authorAmount: tracking.authorAmount,
|
||||
platformCommission: tracking.platformCommission,
|
||||
verified: tracking.verified,
|
||||
timestamp: tracking.timestamp,
|
||||
zapReceiptId: tracking.zapReceiptId,
|
||||
}),
|
||||
}
|
||||
|
||||
const unsignedEvent = {
|
||||
pubkey: authorPubkey,
|
||||
...eventTemplate,
|
||||
}
|
||||
|
||||
const event: Event = {
|
||||
...unsignedEvent,
|
||||
id: getEventHash(unsignedEvent),
|
||||
sig: signEvent(unsignedEvent, authorPrivateKey),
|
||||
} as Event
|
||||
|
||||
const poolWithSub = pool as SimplePoolWithSub
|
||||
const pubs = poolWithSub.publish([RELAY_URL], event)
|
||||
await Promise.all(pubs)
|
||||
const { authorPubkey } = validation
|
||||
const event = buildTrackingEvent(tracking, authorPubkey, authorPrivateKey, this.platformPubkey)
|
||||
await this.publishTrackingEvent(event)
|
||||
|
||||
console.log('Platform tracking event published', {
|
||||
eventId: event.id,
|
||||
@ -127,20 +96,11 @@ export class PlatformTrackingService {
|
||||
return []
|
||||
}
|
||||
|
||||
const filters = [
|
||||
{
|
||||
kinds: [this.trackingKind],
|
||||
'#p': [this.platformPubkey],
|
||||
'#article': [articleId],
|
||||
limit: 100,
|
||||
},
|
||||
]
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const deliveries: ContentDeliveryTracking[] = []
|
||||
let resolved = false
|
||||
const poolWithSub = pool as SimplePoolWithSub
|
||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
||||
const sub = createArticleDeliveriesSubscription(poolWithSub, articleId, this.platformPubkey)
|
||||
|
||||
const finalize = () => {
|
||||
if (resolved) {
|
||||
@ -152,34 +112,9 @@ export class PlatformTrackingService {
|
||||
}
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.content) as ContentDeliveryTracking
|
||||
const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
|
||||
const authorAmountTag = event.tags.find((tag) => tag[0] === 'author_amount')?.[1]
|
||||
const platformCommissionTag = event.tags.find((tag) => tag[0] === 'platform_commission')?.[1]
|
||||
|
||||
const delivery: ContentDeliveryTracking = {
|
||||
...data,
|
||||
}
|
||||
|
||||
if (authorAmountTag) {
|
||||
delivery.authorAmount = parseInt(authorAmountTag, 10)
|
||||
}
|
||||
|
||||
if (platformCommissionTag) {
|
||||
delivery.platformCommission = parseInt(platformCommissionTag, 10)
|
||||
}
|
||||
|
||||
if (zapReceiptTag) {
|
||||
delivery.zapReceiptId = zapReceiptTag
|
||||
}
|
||||
|
||||
const delivery = parseTrackingEvent(event)
|
||||
if (delivery) {
|
||||
deliveries.push(delivery)
|
||||
} catch (error) {
|
||||
console.error('Error parsing tracking event', {
|
||||
eventId: event.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@ -205,20 +140,11 @@ export class PlatformTrackingService {
|
||||
return []
|
||||
}
|
||||
|
||||
const filters = [
|
||||
{
|
||||
kinds: [this.trackingKind],
|
||||
'#p': [this.platformPubkey],
|
||||
'#recipient': [recipientPubkey],
|
||||
limit: 100,
|
||||
},
|
||||
]
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const deliveries: ContentDeliveryTracking[] = []
|
||||
let resolved = false
|
||||
const poolWithSub = pool as SimplePoolWithSub
|
||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
||||
const sub = createRecipientDeliveriesSubscription(poolWithSub, recipientPubkey, this.platformPubkey)
|
||||
|
||||
const finalize = () => {
|
||||
if (resolved) {
|
||||
@ -230,24 +156,9 @@ export class PlatformTrackingService {
|
||||
}
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.content) as ContentDeliveryTracking
|
||||
const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
|
||||
|
||||
const delivery: ContentDeliveryTracking = {
|
||||
...data,
|
||||
}
|
||||
|
||||
if (zapReceiptTag) {
|
||||
delivery.zapReceiptId = zapReceiptTag
|
||||
}
|
||||
|
||||
const delivery = parseTrackingEvent(event)
|
||||
if (delivery) {
|
||||
deliveries.push(delivery)
|
||||
} catch (error) {
|
||||
console.error('Error parsing tracking event', {
|
||||
eventId: event.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
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, PLATFORM_COMMISSIONS } from './platformCommissions'
|
||||
import { automaticTransferService } from './automaticTransfer'
|
||||
import { nostrService } from './nostr'
|
||||
import { lightningAddressService } from './lightningAddress'
|
||||
import type { AlbyInvoice } from '@/types/alby'
|
||||
import type { Event } from 'nostr-tools'
|
||||
import { calculateReviewSplit } from './platformCommissions'
|
||||
import type { ReviewRewardRequest, ReviewRewardResult } from './reviewRewardTypes'
|
||||
import { createReviewInvoice } from './reviewRewardInvoice'
|
||||
import { transferReviewerPortionIfAvailable } from './reviewRewardTransfer'
|
||||
import { trackReviewReward } from './reviewRewardTracking'
|
||||
import { updateReviewWithReward } from './reviewRewardUpdate'
|
||||
|
||||
/**
|
||||
* Review reward service
|
||||
@ -14,46 +13,13 @@ import type { Event } from 'nostr-tools'
|
||||
* - Reviewer: 49 sats
|
||||
* - Platform: 21 sats
|
||||
*/
|
||||
export interface ReviewRewardRequest {
|
||||
reviewId: string
|
||||
articleId: string
|
||||
reviewerPubkey: string
|
||||
reviewerLightningAddress?: string
|
||||
authorPubkey: string
|
||||
authorPrivateKey: string
|
||||
}
|
||||
|
||||
export interface ReviewRewardResult {
|
||||
success: boolean
|
||||
invoice?: AlbyInvoice
|
||||
paymentHash?: string
|
||||
error?: string
|
||||
split: {
|
||||
reviewer: number
|
||||
platform: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
export type { ReviewRewardRequest, ReviewRewardResult } from './reviewRewardTypes'
|
||||
|
||||
export class ReviewRewardService {
|
||||
/**
|
||||
* Create review reward payment with commission split
|
||||
*/
|
||||
async createReviewRewardPayment(request: ReviewRewardRequest): Promise<ReviewRewardResult> {
|
||||
try {
|
||||
const split = calculateReviewSplit()
|
||||
|
||||
// Verify author has permission to reward this review
|
||||
// (should be verified before calling this function)
|
||||
|
||||
const alby = getAlbyService()
|
||||
await alby.enable()
|
||||
|
||||
const invoice = await alby.createInvoice({
|
||||
amount: split.total,
|
||||
description: `Review reward: ${request.reviewId} (${split.reviewer} sats to reviewer, ${split.platform} sats commission)`,
|
||||
expiry: 3600, // 1 hour
|
||||
})
|
||||
const invoice = await createReviewInvoice(split, request)
|
||||
|
||||
console.log('Review reward invoice created', {
|
||||
reviewId: request.reviewId,
|
||||
@ -97,44 +63,9 @@ export class ReviewRewardService {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const split = calculateReviewSplit()
|
||||
|
||||
// Get reviewer Lightning address if not provided
|
||||
let reviewerLightningAddress: string | undefined = request.reviewerLightningAddress
|
||||
if (!reviewerLightningAddress) {
|
||||
const address = await lightningAddressService.getLightningAddress(request.reviewerPubkey)
|
||||
reviewerLightningAddress = address ?? undefined
|
||||
}
|
||||
|
||||
// Transfer reviewer portion
|
||||
if (reviewerLightningAddress) {
|
||||
const transferResult = await automaticTransferService.transferReviewerPortion(
|
||||
reviewerLightningAddress,
|
||||
request.reviewId,
|
||||
request.reviewerPubkey,
|
||||
split.total
|
||||
)
|
||||
|
||||
if (!transferResult.success) {
|
||||
console.error('Failed to transfer reviewer portion', {
|
||||
reviewId: request.reviewId,
|
||||
error: transferResult.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
// Continue anyway - transfer can be done manually later
|
||||
}
|
||||
} else {
|
||||
console.warn('Reviewer Lightning address not available for automatic transfer', {
|
||||
reviewId: request.reviewId,
|
||||
reviewerPubkey: request.reviewerPubkey,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Track the reward payment
|
||||
await this.trackReviewReward(request, split, paymentHash)
|
||||
|
||||
// Update review event with reward tag
|
||||
await this.updateReviewWithReward(request.reviewId, request.authorPrivateKey)
|
||||
await transferReviewerPortionIfAvailable(request, split)
|
||||
await trackReviewReward(request, split, paymentHash)
|
||||
await updateReviewWithReward(request.reviewId, request.authorPrivateKey)
|
||||
|
||||
console.log('Review reward processed', {
|
||||
reviewId: request.reviewId,
|
||||
@ -156,134 +87,6 @@ export class ReviewRewardService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track review reward payment
|
||||
*/
|
||||
private async trackReviewReward(
|
||||
request: ReviewRewardRequest,
|
||||
split: { reviewer: number; platform: number; total: number },
|
||||
paymentHash: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// In production, publish tracking event on Nostr similar to article payments
|
||||
console.log('Review reward tracked', {
|
||||
reviewId: request.reviewId,
|
||||
articleId: request.articleId,
|
||||
reviewerPubkey: request.reviewerPubkey,
|
||||
authorPubkey: request.authorPubkey,
|
||||
reviewerAmount: split.reviewer,
|
||||
platformCommission: split.platform,
|
||||
paymentHash,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error tracking review reward', {
|
||||
reviewId: request.reviewId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update review event with reward tag
|
||||
* Publishes a new event that references the original review with reward tags
|
||||
*/
|
||||
private async updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise<void> {
|
||||
try {
|
||||
const pool = nostrService.getPool()
|
||||
if (!pool) {
|
||||
throw new Error('Pool not initialized')
|
||||
}
|
||||
|
||||
// Get the original event from pool
|
||||
const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
|
||||
const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
|
||||
const filters = [
|
||||
{
|
||||
kinds: [1],
|
||||
ids: [reviewId],
|
||||
limit: 1,
|
||||
},
|
||||
]
|
||||
|
||||
const originalEvent = await new Promise<Event | null>((resolve) => {
|
||||
let resolved = false
|
||||
const sub = poolWithSub.sub([RELAY_URL], filters)
|
||||
|
||||
const finalize = (value: Event | null) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
sub.unsub()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
sub.on('event', (event: Event) => {
|
||||
finalize(event)
|
||||
})
|
||||
|
||||
sub.on('eose', () => finalize(null))
|
||||
setTimeout(() => finalize(null), 5000)
|
||||
})
|
||||
|
||||
if (!originalEvent) {
|
||||
console.error('Original review event not found', {
|
||||
reviewId,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already rewarded
|
||||
const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
|
||||
if (alreadyRewarded) {
|
||||
console.log('Review already marked as rewarded', {
|
||||
reviewId,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create updated event with reward tags
|
||||
nostrService.setPrivateKey(authorPrivateKey)
|
||||
nostrService.setPublicKey(originalEvent.pubkey)
|
||||
|
||||
const updatedEvent = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
...originalEvent.tags.filter((tag) => tag[0] !== 'rewarded' && tag[0] !== 'reward_amount'),
|
||||
['e', reviewId], // Reference to original review
|
||||
['rewarded', 'true'],
|
||||
['reward_amount', PLATFORM_COMMISSIONS.review.total.toString()],
|
||||
],
|
||||
content: originalEvent.content, // Keep original content
|
||||
}
|
||||
|
||||
const publishedEvent = await nostrService.publishEvent(updatedEvent)
|
||||
|
||||
if (publishedEvent) {
|
||||
console.log('Review updated with reward tag', {
|
||||
reviewId,
|
||||
updatedEventId: publishedEvent.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to publish updated review event', {
|
||||
reviewId,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating review with reward', {
|
||||
reviewId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const reviewRewardService = new ReviewRewardService()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user