From 44407fcdd8c156ae11f0c0f300edcdf2e5071296 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 7 Oct 2024 16:43:16 +0200 Subject: [PATCH] update done --- src/application/dtos/ln_address.rs | 18 ++++ src/domains/ln_address/ln_address_handler.rs | 38 +++++++- .../ln_address/ln_address_repository.rs | 1 + src/domains/ln_address/ln_address_service.rs | 84 ++++++++++++++--- .../ln_address/ln_address_use_cases.rs | 7 +- src/domains/user/auth_service.rs | 2 +- src/domains/wallet/user_wallet_handler.rs | 90 ++++++++++++++++++- .../sea_orm_invoice_repository.rs | 8 +- .../sea_orm_ln_address_repository.rs | 22 ++++- .../sea_orm_payment_repository.rs | 6 +- 10 files changed, 248 insertions(+), 28 deletions(-) diff --git a/src/application/dtos/ln_address.rs b/src/application/dtos/ln_address.rs index 6d66c46..07f2b87 100644 --- a/src/application/dtos/ln_address.rs +++ b/src/application/dtos/ln_address.rs @@ -19,3 +19,21 @@ pub struct RegisterLnAddressRequest { #[schema(example = "npub1m8pwckdf3...")] pub nostr_pubkey: Option, } + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateLnAddressRequest { + /// Username such as `username@domain` + pub username: Option, + + /// Active status + #[serde(default)] + pub active: Option, + + /// Nostr enabled + #[serde(default)] + pub allows_nostr: Option, + + /// Nostr public key + #[schema(example = "npub1m8pwckdf3...")] + pub nostr_pubkey: Option, +} diff --git a/src/domains/ln_address/ln_address_handler.rs b/src/domains/ln_address/ln_address_handler.rs index 29bd902..9888c48 100644 --- a/src/domains/ln_address/ln_address_handler.rs +++ b/src/domains/ln_address/ln_address_handler.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use axum::{ extract::{Path, State}, - routing::{delete, get, post}, + routing::{delete, get, post, put}, Json, Router, }; use axum_extra::extract::Query; @@ -15,7 +15,7 @@ use crate::{ BAD_REQUEST_EXAMPLE, FORBIDDEN_EXAMPLE, INTERNAL_EXAMPLE, NOT_FOUND_EXAMPLE, UNAUTHORIZED_EXAMPLE, UNPROCESSABLE_EXAMPLE, }, - dtos::RegisterLnAddressRequest, + dtos::{RegisterLnAddressRequest, UpdateLnAddressRequest}, errors::ApplicationError, }, domains::user::{Permission, User}, @@ -26,7 +26,7 @@ use super::{LnAddress, LnAddressFilter}; #[derive(OpenApi)] #[openapi( - paths(register_address, get_address, list_addresses, delete_address, delete_addresses), + paths(register_address, get_address, list_addresses, update_address, delete_address, delete_addresses), components(schemas(LnAddress, RegisterLnAddressRequest)), tags( (name = "Lightning Addresses", description = "LN Address management endpoints as defined in the [protocol specification](https://lightningaddress.com/). Require `read:ln_address` or `write:ln_address` permissions.") @@ -40,6 +40,7 @@ pub fn router() -> Router> { .route("/", get(list_addresses)) .route("/", post(register_address)) .route("/:id", get(get_address)) + .route("/:id", put(update_address)) .route("/:id", delete(delete_address)) .route("/", delete(delete_addresses)) } @@ -141,6 +142,37 @@ async fn list_addresses( Ok(response.into()) } +/// Update a LN Address +/// +/// Updates an address. Returns the address details. +#[utoipa::path( + put, + path = "/{id}", + tag = "Lightning Addresses", + context_path = CONTEXT_PATH, + request_body = UpdateLnAddressRequest, + responses( + (status = 200, description = "LN Address Updated", body = LnAddress), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 403, description = "Forbidden", body = ErrorResponse, example = json!(FORBIDDEN_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn update_address( + State(app_state): State>, + user: User, + Path(id): Path, + Json(payload): Json, +) -> Result, ApplicationError> { + user.check_permission(Permission::WriteLnAddress)?; + + let ln_address = app_state.services.ln_address.update(id, payload).await?; + Ok(ln_address.into()) +} + /// Delete a LN Address /// /// Deletes an address by ID. Returns an empty body diff --git a/src/domains/ln_address/ln_address_repository.rs b/src/domains/ln_address/ln_address_repository.rs index ebbae44..16a043a 100644 --- a/src/domains/ln_address/ln_address_repository.rs +++ b/src/domains/ln_address/ln_address_repository.rs @@ -20,5 +20,6 @@ pub trait LnAddressRepository: Send + Sync { allows_nostr: bool, nostr_pubkey: Option, ) -> Result; + async fn update(&self, ln_address: LnAddress) -> Result; async fn delete_many(&self, filter: LnAddressFilter) -> Result; } diff --git a/src/domains/ln_address/ln_address_service.rs b/src/domains/ln_address/ln_address_service.rs index a582cf0..f81d9b7 100644 --- a/src/domains/ln_address/ln_address_service.rs +++ b/src/domains/ln_address/ln_address_service.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use crate::{ application::{ + dtos::UpdateLnAddressRequest, entities::AppStore, errors::{ApplicationError, DataError}, }, @@ -39,17 +40,7 @@ impl LnAddressUseCases for LnAddressService { debug!(%wallet_id, username, "Registering lightning address"); username = username.to_lowercase(); - - if username.len() < MIN_USERNAME_LENGTH || username.len() > MAX_USERNAME_LENGTH { - return Err(DataError::Validation("Invalid username length.".to_string()).into()); - } - - // Regex validation for allowed characters in username - let email_username_re = - Regex::new(r"^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$").expect("should not fail as a constant"); - if !email_username_re.is_match(&username) { - return Err(DataError::Validation("Invalid username format.".to_string()).into()); - } + validate_username(username.as_str())?; if self .store @@ -109,6 +100,58 @@ impl LnAddressUseCases for LnAddressService { Ok(ln_addresses) } + async fn update( + &self, + id: Uuid, + request: UpdateLnAddressRequest, + ) -> Result { + debug!(%id, ?request, "Updating lightning address"); + + let mut ln_address = self + .store + .ln_address + .find(id) + .await? + .ok_or_else(|| DataError::NotFound("Lightning address not found.".to_string()))?; + + if let Some(mut username) = request.username { + username = username.to_lowercase(); + + if username != ln_address.username { + validate_username(username.as_str())?; + + if self + .store + .ln_address + .find_by_username(&username) + .await? + .is_some() + { + return Err(DataError::Conflict("Duplicate username.".to_string()).into()); + } + + ln_address.username = username; + } + } + + if let Some(active) = request.active { + ln_address.active = active; + } + + if let Some(allows_nostr) = request.allows_nostr { + ln_address.allows_nostr = allows_nostr; + } + + if let Some(nostr_pubkey) = request.nostr_pubkey { + ln_address.nostr_pubkey = Some(nostr_pubkey); + } + + let ln_address = self.store.ln_address.update(ln_address).await?; + + info!(%id, "Lightning address updated successfully"); + Ok(ln_address) + } + async fn delete(&self, id: Uuid) -> Result<(), ApplicationError> { debug!(%id, "Deleting lightning address"); @@ -141,3 +184,22 @@ impl LnAddressUseCases for LnAddressService { Ok(n_deleted) } } + +fn validate_username(username: &str) -> Result<(), DataError> { + if username.len() < MIN_USERNAME_LENGTH || username.len() > MAX_USERNAME_LENGTH { + return Err(DataError::Validation( + "Invalid username length.".to_string(), + )); + } + + // Regex validation for allowed characters in username + let email_username_re = + Regex::new(r"^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$").expect("should not fail as a constant"); + if !email_username_re.is_match(username) { + return Err(DataError::Validation( + "Invalid username format.".to_string(), + )); + } + + Ok(()) +} diff --git a/src/domains/ln_address/ln_address_use_cases.rs b/src/domains/ln_address/ln_address_use_cases.rs index b05f1cb..fddf02f 100644 --- a/src/domains/ln_address/ln_address_use_cases.rs +++ b/src/domains/ln_address/ln_address_use_cases.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use nostr_sdk::PublicKey; use uuid::Uuid; -use crate::application::errors::ApplicationError; +use crate::application::{dtos::UpdateLnAddressRequest, errors::ApplicationError}; use super::{LnAddress, LnAddressFilter}; @@ -18,6 +18,11 @@ pub trait LnAddressUseCases: Send + Sync { ) -> Result; async fn get(&self, id: Uuid) -> Result; async fn list(&self, filter: LnAddressFilter) -> Result, ApplicationError>; + async fn update( + &self, + id: Uuid, + request: UpdateLnAddressRequest, + ) -> Result; async fn delete(&self, id: Uuid) -> Result<(), ApplicationError>; async fn delete_many(&self, filter: LnAddressFilter) -> Result; } diff --git a/src/domains/user/auth_service.rs b/src/domains/user/auth_service.rs index f822f2f..93b21c9 100644 --- a/src/domains/user/auth_service.rs +++ b/src/domains/user/auth_service.rs @@ -58,7 +58,7 @@ impl AuthUseCases for AuthService { let wallet_opt = self.store.wallet.find_by_user_id(&claims.sub).await?; let wallet = match wallet_opt { - Some(user) => user, + Some(wallet) => wallet, None => { let wallet = self.store.wallet.insert(&claims.sub).await?; diff --git a/src/domains/wallet/user_wallet_handler.rs b/src/domains/wallet/user_wallet_handler.rs index 92b0c3f..0ed18d0 100644 --- a/src/domains/wallet/user_wallet_handler.rs +++ b/src/domains/wallet/user_wallet_handler.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use axum::{ extract::{Path, Query, State}, - routing::{delete, get, post}, + routing::{delete, get, post, put}, Json, Router, }; use utoipa::OpenApi; @@ -16,7 +16,7 @@ use crate::{ }, dtos::{ InvoiceResponse, NewInvoiceRequest, PaymentResponse, RegisterLnAddressRequest, - SendPaymentRequest, WalletResponse, + SendPaymentRequest, UpdateLnAddressRequest, WalletResponse, }, errors::{ApplicationError, DataError}, }, @@ -33,7 +33,7 @@ use super::{Balance, Contact}; #[derive(OpenApi)] #[openapi( - paths(get_user_wallet, get_wallet_balance, get_wallet_address, register_wallet_address, wallet_pay, list_wallet_payments, + paths(get_user_wallet, get_wallet_balance, get_wallet_address, register_wallet_address, update_wallet_address, delete_wallet_address, wallet_pay, list_wallet_payments, get_wallet_payment, delete_failed_payments, list_wallet_invoices, get_wallet_invoice, new_wallet_invoice, delete_expired_invoices, list_contacts ), @@ -51,6 +51,8 @@ pub fn user_router() -> Router> { .route("/balance", get(get_wallet_balance)) .route("/lightning-address", get(get_wallet_address)) .route("/lightning-address", post(register_wallet_address)) + .route("/lightning-address", put(update_wallet_address)) + .route("/lightning-address", delete(delete_wallet_address)) .route("/payments", post(wallet_pay)) .route("/payments", get(list_wallet_payments)) .route("/payments/:id", get(get_wallet_payment)) @@ -254,6 +256,88 @@ async fn register_wallet_address( Ok(ln_address.into()) } +/// Updates the user's LN Address +/// +/// Updates the address. Returns the address details. +#[utoipa::path( + put, + path = "/lightning-address", + tag = "User Wallet", + context_path = CONTEXT_PATH, + request_body = UpdateLnAddressRequest, + responses( + (status = 200, description = "LN Address Updated", body = LnAddress), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 422, description = "Unprocessable Entity", body = ErrorResponse, example = json!(UNPROCESSABLE_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn update_wallet_address( + State(app_state): State>, + user: User, + Json(payload): Json, +) -> Result, ApplicationError> { + let ln_addresses = app_state + .services + .ln_address + .list(LnAddressFilter { + wallet_id: Some(user.wallet_id), + ..Default::default() + }) + .await?; + + let ln_address = ln_addresses + .first() + .cloned() + .ok_or_else(|| DataError::NotFound("LN Address not found.".to_string()))?; + + let ln_address = app_state + .services + .ln_address + .update(ln_address.id, payload) + .await?; + + Ok(ln_address.into()) +} + +/// Deletes the user's LN Address +/// +/// Deletes an address. Returns an empty body. Once the address is deleted, it will no longer be able to receive funds and its username can be claimed by another user. +#[utoipa::path( + delete, + path = "/lightning-address", + tag = "User Wallet", + context_path = CONTEXT_PATH, + responses( + (status = 200, description = "Deleted"), + (status = 400, description = "Bad Request", body = ErrorResponse, example = json!(BAD_REQUEST_EXAMPLE)), + (status = 401, description = "Unauthorized", body = ErrorResponse, example = json!(UNAUTHORIZED_EXAMPLE)), + (status = 404, description = "Not Found", body = ErrorResponse, example = json!(NOT_FOUND_EXAMPLE)), + (status = 500, description = "Internal Server Error", body = ErrorResponse, example = json!(INTERNAL_EXAMPLE)) + ) +)] +async fn delete_wallet_address( + State(app_state): State>, + user: User, +) -> Result<(), ApplicationError> { + let n_deleted = app_state + .services + .ln_address + .delete_many(LnAddressFilter { + wallet_id: Some(user.wallet_id), + ..Default::default() + }) + .await?; + + if n_deleted == 0 { + return Err(DataError::NotFound("Lightning address not found.".to_string()).into()); + } + + Ok(()) +} + /// List payments /// /// Returns all the payments given a filter diff --git a/src/infra/database/sea_orm/repositories/sea_orm_invoice_repository.rs b/src/infra/database/sea_orm/repositories/sea_orm_invoice_repository.rs index 1bd996f..78d5276 100644 --- a/src/infra/database/sea_orm/repositories/sea_orm_invoice_repository.rs +++ b/src/infra/database/sea_orm/repositories/sea_orm_invoice_repository.rs @@ -5,9 +5,9 @@ use crate::{ }; use async_trait::async_trait; use sea_orm::{ - sea_query::Expr, ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, - DatabaseConnection, DatabaseTransaction, EntityTrait, QueryFilter, QueryOrder, QuerySelect, - QueryTrait, + sea_query::Expr, ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, + DatabaseTransaction, EntityTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait, Set, + Unchanged, }; use uuid::Uuid; @@ -134,7 +134,7 @@ impl InvoiceRepository for SeaOrmInvoiceRepository { invoice: Invoice, ) -> Result { let model = ActiveModel { - id: Set(invoice.id), + id: Unchanged(invoice.id), fee_msat: Set(invoice.fee_msat.map(|v| v as i64)), payment_time: Set(invoice.payment_time), description: Set(invoice.description), diff --git a/src/infra/database/sea_orm/repositories/sea_orm_ln_address_repository.rs b/src/infra/database/sea_orm/repositories/sea_orm_ln_address_repository.rs index dd3c4e4..937c039 100644 --- a/src/infra/database/sea_orm/repositories/sea_orm_ln_address_repository.rs +++ b/src/infra/database/sea_orm/repositories/sea_orm_ln_address_repository.rs @@ -1,9 +1,8 @@ use async_trait::async_trait; use nostr_sdk::PublicKey; -use sea_orm::ActiveValue::Set; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, - QuerySelect, QueryTrait, + QuerySelect, QueryTrait, Set, Unchanged, }; use uuid::Uuid; @@ -97,6 +96,25 @@ impl LnAddressRepository for SeaOrmLnAddressRepository { Ok(model.into()) } + async fn update(&self, ln_address: LnAddress) -> Result { + let model = ActiveModel { + id: Unchanged(ln_address.id), + wallet_id: Unchanged(ln_address.wallet_id), + username: Set(ln_address.username), + allows_nostr: Set(ln_address.allows_nostr), + nostr_pubkey: Set(ln_address.nostr_pubkey.map(|k| k.to_hex())), + active: Set(ln_address.active), + ..Default::default() + }; + + let model = model + .update(&self.db) + .await + .map_err(|e| DatabaseError::Update(e.to_string()))?; + + Ok(model.into()) + } + async fn delete_many(&self, filter: LnAddressFilter) -> Result { let result = Entity::delete_many() .apply_if(filter.wallet_id, |q, id| q.filter(Column::WalletId.eq(id))) diff --git a/src/infra/database/sea_orm/repositories/sea_orm_payment_repository.rs b/src/infra/database/sea_orm/repositories/sea_orm_payment_repository.rs index 4f19113..106c273 100644 --- a/src/infra/database/sea_orm/repositories/sea_orm_payment_repository.rs +++ b/src/infra/database/sea_orm/repositories/sea_orm_payment_repository.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, DatabaseTransaction, - EntityTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait, + ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, + QueryFilter, QueryOrder, QuerySelect, QueryTrait, Set, Unchanged, }; use uuid::Uuid; @@ -100,7 +100,7 @@ impl PaymentRepository for SeaOrmPaymentRepository { async fn update(&self, payment: Payment) -> Result { let model = ActiveModel { - id: Set(payment.id), + id: Unchanged(payment.id), status: Set(payment.status.to_string()), fee_msat: Set(payment.fee_msat.map(|v| v as i64)), payment_time: Set(payment.payment_time),