forked from coracle/caravel
Fix compile errors
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
ref
|
||||
node_modules
|
||||
target
|
||||
data
|
||||
.env
|
||||
**/.env
|
||||
|
||||
Generated
+13
@@ -12,6 +12,17 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -191,6 +202,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"hex",
|
||||
@@ -1385,6 +1397,7 @@ version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d90b55eff1f0747d9e423972179672e1aacac3d3ccee4c1281147eaa90d6491e"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
"bech32",
|
||||
"bip39",
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "macros"
|
||||
tokio = { version = "1.36", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
nostr-sdk = "0.39"
|
||||
nostr-sdk = { version = "0.39", features = ["nip04", "nip47", "nip59", "nip98"] }
|
||||
uuid = { version = "1.7", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
@@ -20,3 +20,4 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
dotenvy = "0.15.7"
|
||||
base64 = "0.22"
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, Method, StatusCode, Uri},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post, put},
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -325,7 +325,7 @@ async fn list_tenant_invoices(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct UpdateTenantBillingRequest {
|
||||
tenant_nwc_url: String,
|
||||
}
|
||||
|
||||
+62
-4
@@ -1,15 +1,73 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::engine::general_purpose;
|
||||
use base64::Engine;
|
||||
use std::str::FromStr;
|
||||
|
||||
use nostr_sdk::nostr::key::PublicKey;
|
||||
use nostr_sdk::nostr::nips::nip98::{self, HttpMethod};
|
||||
use nostr_sdk::nostr::nips::nip98::HttpMethod;
|
||||
use nostr_sdk::nostr::types::time::Timestamp;
|
||||
use nostr_sdk::nostr::types::url::Url;
|
||||
use nostr_sdk::nostr::{Alphabet, Event, Kind, SingleLetterTag, TagKind, TagStandard};
|
||||
use nostr_sdk::JsonUtil;
|
||||
|
||||
pub fn verify_nip98(auth_header: &str, url: &str, method: &str) -> Result<PublicKey> {
|
||||
let url = Url::parse(url).map_err(|e| anyhow!(e))?;
|
||||
let method = HttpMethod::from_str(&method.to_uppercase()).map_err(|e| anyhow!(e))?;
|
||||
let url = Url::parse(url)?;
|
||||
let method = HttpMethod::from_str(&method.to_uppercase())?;
|
||||
let now = Timestamp::now();
|
||||
|
||||
nip98::verify_auth_header(auth_header, &url, method, now, None).map_err(|e| anyhow!(e))
|
||||
let event = decode_auth_event(auth_header)?;
|
||||
|
||||
if event.kind != Kind::HttpAuth {
|
||||
return Err(anyhow!("authorization event kind mismatch"));
|
||||
}
|
||||
|
||||
let authorized_url =
|
||||
match event
|
||||
.tags
|
||||
.find_standardized(TagKind::SingleLetter(SingleLetterTag::lowercase(
|
||||
Alphabet::U,
|
||||
))) {
|
||||
Some(TagStandard::AbsoluteURL(url)) => url,
|
||||
_ => return Err(anyhow!("authorization header missing url tag")),
|
||||
};
|
||||
|
||||
let authorized_method = match event.tags.find_standardized(TagKind::Method) {
|
||||
Some(TagStandard::Method(method)) => method,
|
||||
_ => return Err(anyhow!("authorization header missing method tag")),
|
||||
};
|
||||
|
||||
if authorized_url != &url || authorized_method != &method {
|
||||
return Err(anyhow!("authorization does not match request"));
|
||||
}
|
||||
|
||||
let created_at = event.created_at.as_u64();
|
||||
let now_secs = now.as_u64();
|
||||
if created_at > now_secs {
|
||||
if created_at - now_secs > 30 {
|
||||
return Err(anyhow!("authorization timestamp too far in future"));
|
||||
}
|
||||
} else if now_secs - created_at > 60 {
|
||||
return Err(anyhow!("authorization timestamp too old"));
|
||||
}
|
||||
|
||||
event.verify()?;
|
||||
Ok(event.pubkey)
|
||||
}
|
||||
|
||||
fn decode_auth_event(auth_header: &str) -> Result<Event> {
|
||||
if auth_header.trim().is_empty() {
|
||||
return Err(anyhow!("missing authorization header"));
|
||||
}
|
||||
|
||||
let (prefix, encoded) = auth_header
|
||||
.split_once(' ')
|
||||
.ok_or_else(|| anyhow!("malformed authorization header"))?;
|
||||
|
||||
if prefix != "Nostr" || encoded.is_empty() {
|
||||
return Err(anyhow!("malformed authorization header"));
|
||||
}
|
||||
|
||||
let decoded = general_purpose::STANDARD.decode(encoded)?;
|
||||
let json = String::from_utf8(decoded)?;
|
||||
Ok(Event::from_json(json)?)
|
||||
}
|
||||
|
||||
+42
-8
@@ -7,7 +7,8 @@ use crate::models::{Invoice, NewInvoice, NewInvoiceItem, Relay, Tenant};
|
||||
use crate::notifications::Nip17Notifier;
|
||||
use crate::repo::Repo;
|
||||
|
||||
use nostr_sdk::nwc::prelude::*;
|
||||
use nostr_sdk::nips::nip47::{self, MakeInvoiceRequest, NostrWalletConnectURI, PayInvoiceRequest};
|
||||
use nostr_sdk::{Client, Filter, Kind, Keys, Timestamp};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BillingService {
|
||||
@@ -140,20 +141,53 @@ impl BillingService {
|
||||
}
|
||||
|
||||
let uri = NostrWalletConnectURI::parse(&self.platform_nwc_url)?;
|
||||
let nwc = NWC::new(uri);
|
||||
let request = MakeInvoiceRequest::new(amount as u64, "Relay hosting");
|
||||
let response = nwc.make_invoice(request).await?;
|
||||
Ok(response.invoice)
|
||||
let request = nip47::Request::make_invoice(MakeInvoiceRequest {
|
||||
amount: (amount as u64) * 1_000,
|
||||
description: Some("Relay hosting".to_string()),
|
||||
description_hash: None,
|
||||
expiry: None,
|
||||
});
|
||||
let response = self.send_nwc_request(&uri, request).await?;
|
||||
Ok(response.to_make_invoice()?.invoice)
|
||||
}
|
||||
|
||||
async fn pay_invoice(&self, tenant_nwc_url: &str, invoice: &str) -> Result<()> {
|
||||
let uri = NostrWalletConnectURI::parse(tenant_nwc_url)?;
|
||||
let nwc = NWC::new(uri);
|
||||
let request = PayInvoiceRequest::new(invoice);
|
||||
nwc.pay_invoice(request).await?;
|
||||
let request = nip47::Request::pay_invoice(PayInvoiceRequest::new(invoice));
|
||||
self.send_nwc_request(&uri, request).await?.to_pay_invoice()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_nwc_request(
|
||||
&self,
|
||||
uri: &NostrWalletConnectURI,
|
||||
request: nip47::Request,
|
||||
) -> Result<nip47::Response> {
|
||||
let app_keys = Keys::new(uri.secret.clone());
|
||||
let app_pubkey = app_keys.public_key();
|
||||
let client = Client::new(app_keys);
|
||||
client.add_relay(uri.relay_url.clone()).await?;
|
||||
client.connect().await;
|
||||
|
||||
let started_at = Timestamp::now();
|
||||
let event = request.to_event(uri)?;
|
||||
client.send_event(event).await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::WalletConnectResponse)
|
||||
.author(uri.public_key)
|
||||
.pubkey(app_pubkey)
|
||||
.since(started_at);
|
||||
|
||||
let events = client.fetch_events(filter, Duration::from_secs(10)).await?;
|
||||
let event = events
|
||||
.into_iter()
|
||||
.max_by_key(|event| event.created_at)
|
||||
.ok_or_else(|| anyhow!("no NWC response"))?;
|
||||
|
||||
Ok(nip47::Response::from_event(uri, &event)?)
|
||||
}
|
||||
|
||||
async fn send_invoice_dm(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
@@ -21,6 +22,7 @@ impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
let database_url =
|
||||
env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://data/caravel.db".to_string());
|
||||
let database_url = resolve_database_url(database_url);
|
||||
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port = env::var("PORT")
|
||||
.ok()
|
||||
@@ -71,3 +73,20 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_database_url(database_url: String) -> String {
|
||||
const PREFIX: &str = "sqlite://";
|
||||
if !database_url.starts_with(PREFIX) {
|
||||
return database_url;
|
||||
}
|
||||
|
||||
let path = &database_url[PREFIX.len()..];
|
||||
if path.is_empty() || path.starts_with('/') || path == ":memory:" {
|
||||
return database_url;
|
||||
}
|
||||
|
||||
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let absolute = base.join(path);
|
||||
let normalized = absolute.to_string_lossy();
|
||||
format!("sqlite:///{}", normalized.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
+36
-3
@@ -1,15 +1,48 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, SqlitePool};
|
||||
use anyhow::{anyhow, Result};
|
||||
use sqlx::{migrate::Migrator, sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, SqlitePool};
|
||||
use std::str::FromStr;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
pub async fn init_pool(database_url: &str) -> Result<SqlitePool> {
|
||||
ensure_sqlite_directory(database_url)?;
|
||||
let options = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(database_url)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
sqlx::query("PRAGMA journal_mode = WAL;")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
MIGRATOR.run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
fn ensure_sqlite_directory(database_url: &str) -> Result<()> {
|
||||
const PREFIX: &str = "sqlite://";
|
||||
if !database_url.starts_with(PREFIX) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = &database_url[PREFIX.len()..];
|
||||
if path.is_empty() || path == ":memory:" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = if let Some(stripped) = path.strip_prefix('/') {
|
||||
format!("/{}", stripped)
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
|
||||
let parent = Path::new(&path)
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("invalid sqlite path"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ impl Nip17Notifier {
|
||||
return Err(anyhow!("PLATFORM_SECRET is required for NIP-17 notifications"));
|
||||
}
|
||||
|
||||
let keys = Keys::parse(platform_secret)?;
|
||||
let keys = Keys::parse(&platform_secret)?;
|
||||
let indexer_client = Client::new(keys.clone());
|
||||
|
||||
for relay in &relays {
|
||||
@@ -71,15 +71,15 @@ impl Nip17Notifier {
|
||||
let filter = Filter::new().kind(Kind::Custom(10050)).author(pubkey);
|
||||
let events = self
|
||||
.indexer_client
|
||||
.get_events_of(vec![filter], Some(Duration::from_secs(5)))
|
||||
.fetch_events(filter, Duration::from_secs(5))
|
||||
.await?;
|
||||
|
||||
let mut relays = Vec::new();
|
||||
if let Some(event) = events.into_iter().max_by_key(|event| event.created_at) {
|
||||
for tag in event.tags.iter() {
|
||||
if let Some(first) = tag.as_vec().get(0) {
|
||||
if let Some(first) = tag.as_slice().get(0) {
|
||||
if first == "relay" {
|
||||
if let Some(value) = tag.as_vec().get(1) {
|
||||
if let Some(value) = tag.as_slice().get(1) {
|
||||
relays.push(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ impl Provisioner {
|
||||
return Err(anyhow!("PLATFORM_SECRET is required"));
|
||||
}
|
||||
|
||||
let admin_keys = Keys::parse(admin_secret)?;
|
||||
let admin_keys = Keys::parse(&admin_secret)?;
|
||||
let client = Client::new();
|
||||
|
||||
Ok(Self {
|
||||
|
||||
@@ -76,3 +76,20 @@ Super admin dashboard:
|
||||
- `/admin/relays` — list relays
|
||||
- `/admin/relays/:id` — relay detail
|
||||
- `/admin/relays/:id/edit` — edit relay
|
||||
|
||||
## Todos
|
||||
|
||||
- [ ] Marketing page (`/`) with value props, features, and CTA
|
||||
- [ ] Login page (`/login`) via nonboard
|
||||
- [ ] Tenant dashboard auth via NIP-98
|
||||
- [ ] Relays list (`/relays`) with search/filter and add relay CTA
|
||||
- [ ] Relay detail (`/relays/:id`) with edit + deactivate actions
|
||||
- [ ] New relay form (`/relays/new`) with plan selection + invoice flow
|
||||
- [ ] Relay edit form (`/relays/:id/edit`)
|
||||
- [ ] Account page (`/account`) with status, invoices, and recurring billing toggle
|
||||
- [ ] Super admin dashboard auth via `PLATFORM_ADMIN_PUBKEYS`
|
||||
- [ ] Tenants list (`/admin/tenants`)
|
||||
- [ ] Tenant detail (`/admin/tenants/:id`) with status + deactivate actions
|
||||
- [ ] Relays list (`/admin/relays`)
|
||||
- [ ] Relay detail (`/admin/relays/:id`)
|
||||
- [ ] Relay edit form (`/admin/relays/:id/edit`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "../node_modules/preline/variants.css";
|
||||
@source "../node_modules/preline/dist/*.js";
|
||||
@import "nonboard/style.css";
|
||||
|
||||
/* Pointer cursor on buttons */
|
||||
@layer base {
|
||||
|
||||
@@ -21,10 +21,52 @@ export default function Login() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 w-full max-w-md">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6 text-center">Log In</h1>
|
||||
<div ref={container} />
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-100 flex items-center justify-center p-6">
|
||||
<div class="w-full max-w-3xl">
|
||||
<div class="relative overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(99,102,241,0.08),_transparent_45%)]" />
|
||||
<div class="relative grid gap-8 p-8 md:grid-cols-[1.2fr_1fr] md:p-10">
|
||||
<div class="space-y-6">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700">
|
||||
Secure Nostr Login
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold text-gray-900 md:text-4xl">Welcome back</h1>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Connect your Nostr account to manage relay hosting, billing, and access in one place.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-gray-600">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
|
||||
<p>Own your identity with cryptographic sign-in.</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
|
||||
<p>No passwords or email required.</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-1 h-2 w-2 rounded-full bg-indigo-500" />
|
||||
<p>Fast access to your relays and invoices.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-6 shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Log in with Nostr</h2>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
New here? Create an account in seconds using your favorite Nostr signer.
|
||||
</p>
|
||||
<div class="mt-6" ref={container} />
|
||||
<p class="mt-6 text-xs text-gray-500">
|
||||
By continuing, you agree to use NIP-98 authentication for secure access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-6 text-center text-xs text-gray-500">
|
||||
Having trouble? Make sure your signer is unlocked and connected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user