Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ These docs apply to all projects using this agent structure:
| [docs/agents/build-and-test.md](docs/agents/build-and-test.md) | Running builds, tests, clippy, or formatting |
| [docs/agents/code-style.md](docs/agents/code-style.md) | Writing or reviewing Rust code (imports, errors, naming, async, derives, serde, tests) |
| [docs/agents/api-guidelines.md](docs/agents/api-guidelines.md) | Modifying any user-facing or admin API endpoint |
| [docs/agents/migrations.md](docs/agents/migrations.md) | Adding or modifying database migrations |
| [docs/agents/currency.md](docs/agents/currency.md) | Working with money amounts, pricing, or payments |
| [docs/agents/bug-fixes.md](docs/agents/bug-fixes.md) | Resolving bugs — LNVPS-specific additions |
| [docs/agents/coverage.md](docs/agents/coverage.md) | Function coverage — LNVPS-specific additions |
10 changes: 10 additions & 0 deletions API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface AuthHeaders {
```typescript
interface AccountInfo {
email?: string;
email_verified?: boolean; // Present when email is set; true if email has been verified
contact_nip17: boolean;
contact_email: boolean;
country_code?: string; // ISO 3166-1 alpha-3 country code
Expand Down Expand Up @@ -328,6 +329,15 @@ interface VmUpgradeQuote {
- **PATCH** `/api/v1/account`
- **Auth**: Required
- **Body**: `AccountInfo`
- **Notes**:
- Setting `contact_email: true` requires an email address to be present
- When email is changed, a verification email is sent and `email_verified` is reset to `false`
- **Response**: `null`

#### Verify Email Address
- **GET** `/api/v1/account/verify-email?token=<token>`
- **Auth**: Not required
- **Query**: `token` — the verification token from the verification email
- **Response**: `null`

### Automatic Renewal with Nostr Wallet Connect
Expand Down
29 changes: 29 additions & 0 deletions docs/agents/migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Database Migrations

## Timestamp Rules

**CRITICAL:** Migration filenames must have unique timestamps (full 14-digit `YYYYMMDDHHMMSS`). Before creating or modifying a migration:

1. **Check existing timestamps** — Run `ls lnvps_db/migrations/` and verify your full timestamp doesn't conflict
2. **After rebasing** — If your branch adds migrations, check that the timestamps don't collide with migrations added to master
3. **Use the current timestamp** — Generate with `date +%Y%m%d%H%M%S`

Migration format: `YYYYMMDDHHMMSS_description.sql`

Example conflict to avoid:
```
20260219000000_cpu_type.sql # from master
20260219000000_email_verification.sql # CONFLICT — same full timestamp!
```

Fix by using a completely unique timestamp:
```
20260219000000_cpu_type.sql
20260221120000_email_verification.sql # different date AND time
```

## Migration Best Practices

- Use `NOT NULL DEFAULT <value>` for new columns to avoid breaking existing rows
- Test migrations against a database with production-like data
- Never modify a migration that has already been applied to any environment
12 changes: 10 additions & 2 deletions lnvps_api/email.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@
white-space: pre-wrap;
word-wrap: break-word;
}

a {
color: #3498db;
}

a:hover {
color: #5dade2;
}
</style>
</head>
<body>
Expand All @@ -46,10 +54,10 @@
<img height="48" width="48" src="https://lnvps.net/logo.jpg" alt="logo"/>
</div>
<hr/>
<p>%%_MESSAGE_%%</p>
<p>{{{message}}}</p>
<hr/>
<small>
(c) %%YEAR%% LNVPS.net
(c) {{year}} LNVPS.net
</small>
</div>
</body>
Expand Down
8 changes: 7 additions & 1 deletion lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ pub struct AccountPatchRequest {
deserialize_with = "lnvps_api_common::deserialize_nullable_option"
)]
pub email: Option<Option<String>>,
/// Whether the email address has been verified (read-only, ignored on PATCH)
#[serde(skip_deserializing, skip_serializing_if = "Option::is_none")]
pub email_verified: Option<bool>,
pub contact_nip17: bool,
pub contact_email: bool,
#[serde(
Expand Down Expand Up @@ -132,8 +135,11 @@ pub struct AccountPatchRequest {

impl From<lnvps_db::User> for AccountPatchRequest {
fn from(user: lnvps_db::User) -> Self {
let has_email = !user.email.is_empty();
let email_str: String = user.email.into();
AccountPatchRequest {
email: Some(user.email.map(|e| e.into())),
email: if has_email { Some(Some(email_str)) } else { None },
email_verified: has_email.then_some(user.email_verified),
contact_nip17: user.contact_nip17,
contact_email: user.contact_email,
country_code: Some(user.country_code),
Expand Down
124 changes: 120 additions & 4 deletions lnvps_api/src/api/routes.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::Result;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Path, Query, State, WebSocketUpgrade};
use axum::response::Html;
use axum::response::{Html, IntoResponse};
use axum::routing::{any, get, patch, post};
use axum::{Json, Router};
use chrono::{DateTime, Datelike, Utc};
Expand Down Expand Up @@ -41,6 +41,10 @@ pub fn routes() -> Router<RouterState> {
"/api/v1/account",
get(v1_get_account).patch(v1_patch_account),
)
.route(
"/api/v1/account/verify-email",
get(v1_verify_email),
)
.route("/api/v1/vm", get(v1_list_vms))
.route("/api/v1/vm/{id}", get(v1_get_vm).patch(v1_patch_vm))
.route("/api/v1/image", get(v1_list_vm_images))
Expand Down Expand Up @@ -131,10 +135,38 @@ async fn v1_patch_account(
}
}

// Update fields only if they are present in the request
if let Some(email) = &req.email {
user.email = email.clone().map(|s| s.into());
// validate and handle email change
let mut pending_verification: Option<String> = None;
if let Some(new_email_opt) = &req.email {
if let Some(new_email) = new_email_opt {
// Validate email format
if new_email.trim().is_empty() {
return ApiData::err("Email address cannot be empty");
}
if !new_email.contains('@') || !new_email.contains('.') {
return ApiData::err("Invalid email address");
}
// Check if email is changing
let old_email = user.email.as_str().to_string();
let email_changed = old_email != new_email.as_str();
user.email = new_email.clone().into();
if email_changed {
// Mark email as unverified and generate a verification token
let token = hex::encode(rand::random::<[u8; 32]>());
user.email_verified = false;
user.email_verify_token = token.clone();
pending_verification = Some(token);
}
} else {
return ApiData::err("Email address is required and cannot be removed");
}
}

// If contact_email is enabled, email must be set
if req.contact_email && user.email.is_empty() {
return ApiData::err("An email address is required to enable email notifications");
}

user.contact_nip17 = req.contact_nip17;
user.contact_email = req.contact_email;
if let Some(country_code) = &req.country_code {
Expand Down Expand Up @@ -169,9 +201,93 @@ async fn v1_patch_account(
}

this.db.update_user(&user).await?;

// Queue verification email after successful save
if let Some(token) = pending_verification {
let verify_url = format!(
"{}/api/v1/account/verify-email?token={}",
this.settings.public_url, token
);
if let Err(e) = this
.work_sender
.send(WorkJob::SendEmailVerification {
user_id: uid,
verify_url,
})
.await
{
error!("Failed to queue email verification: {}", e);
}
}

ApiData::ok(())
}

#[derive(serde::Serialize)]
struct VerifyEmailPage {
title: String,
message: String,
color: String,
}

/// Verify email address using the token sent to the user's email
async fn v1_verify_email(
Comment thread
v0l marked this conversation as resolved.
State(this): State<RouterState>,
Query(params): Query<VerifyEmailQuery>,
) -> impl IntoResponse {
let make_page = |title: &str, message: &str, color: &str| {
let template = mustache::compile_str(include_str!("../../verify-email.html"))
.expect("valid verify-email template");
let data = VerifyEmailPage {
title: title.to_string(),
message: message.to_string(),
color: color.to_string(),
};
let rendered = template
.render_to_string(&data)
.unwrap_or_else(|_| format!("<h1>{title}</h1><p>{message}</p>"));
Html(rendered)
};

if params.token.trim().is_empty() {
return make_page(
"Invalid Link",
"The verification link is missing a token.",
"#e74c3c",
);
}
let mut user = match this.db.get_user_by_email_verify_token(&params.token).await {
Ok(u) => u,
Err(_) => {
return make_page(
"Invalid or Expired Link",
"This verification link is invalid or has already been used.",
"#e74c3c",
);
}
};
user.email_verified = true;
user.email_verify_token = String::new();
if let Err(e) = this.db.update_user(&user).await {
error!("Failed to mark email verified: {}", e);
return make_page(
"Error",
"An error occurred. Please try again later.",
"#e74c3c",
);
}
make_page(
"Email Verified",
"Your email address has been successfully verified.",
"#2ecc71",
)
}

#[derive(serde::Deserialize)]
struct VerifyEmailQuery {
token: String,
}

/// Get user account detail
async fn v1_get_account(
auth: Nip98Auth,
Expand Down
Loading
Loading