| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
use serde::{Deserialize, Serialize}; |
| 9 |
use std::collections::BTreeMap; |
| 10 |
|
| 11 |
use crate::currency::{Currency, RevenueByCurrency}; |
| 12 |
|
| 13 |
mod analytics; |
| 14 |
mod domains; |
| 15 |
mod git; |
| 16 |
mod items; |
| 17 |
mod projects; |
| 18 |
mod storefront; |
| 19 |
mod uploads; |
| 20 |
|
| 21 |
pub(crate) use analytics::{AnalyticsData, Transaction}; |
| 22 |
pub(crate) use git::SshKeyInfo; |
| 23 |
pub(crate) use items::{ItemDetail, TagInfo, Version}; |
| 24 |
pub(crate) use projects::{CreatorStats, Item, Project, StorageInfo}; |
| 25 |
pub(crate) use storefront::{BlogPost, CollectionInfo, LicenseKey, PromoCode, TierInfo}; |
| 26 |
pub(crate) use uploads::MULTIPART_THRESHOLD_BYTES; |
| 27 |
|
| 28 |
|
| 29 |
#[derive(Debug, Clone, Deserialize, Serialize)] |
| 30 |
pub(crate) struct UserInfo { |
| 31 |
pub user_id: String, |
| 32 |
pub username: String, |
| 33 |
pub display_name: Option<String>, |
| 34 |
pub creator_tier: Option<String>, |
| 35 |
pub can_create_projects: bool, |
| 36 |
pub suspended: bool, |
| 37 |
|
| 38 |
|
| 39 |
|
| 40 |
#[serde(default)] |
| 41 |
pub actor_token: String, |
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
#[serde(default)] |
| 46 |
pub settlement_currency: Currency, |
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
#[serde(default)] |
| 55 |
pub theme_id: Option<String>, |
| 56 |
} |
| 57 |
|
| 58 |
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
pub(super) fn revenue_of( |
| 70 |
cents: i64, |
| 71 |
currency: Currency, |
| 72 |
by_currency: &BTreeMap<String, i64>, |
| 73 |
) -> RevenueByCurrency { |
| 74 |
if by_currency.is_empty() { |
| 75 |
RevenueByCurrency::from_rows([(currency, cents)]) |
| 76 |
} else { |
| 77 |
RevenueByCurrency::from_wire_map(by_currency) |
| 78 |
} |
| 79 |
} |
| 80 |
|
| 81 |
|
| 82 |
|
| 83 |
|
| 84 |
|
| 85 |
async fn bail_for_status( |
| 86 |
resp: reqwest::Response, |
| 87 |
context: &str, |
| 88 |
) -> anyhow::Result<reqwest::Response> { |
| 89 |
if resp.status().is_success() { |
| 90 |
return Ok(resp); |
| 91 |
} |
| 92 |
let status = resp.status(); |
| 93 |
let body = resp.text().await.unwrap_or_else(|e| { |
| 94 |
tracing::warn!(error = %e, %context, "failed to read error response body"); |
| 95 |
String::new() |
| 96 |
}); |
| 97 |
if body.is_empty() { |
| 98 |
anyhow::bail!("{context} failed: HTTP {status}"); |
| 99 |
} |
| 100 |
anyhow::bail!("{context} failed: HTTP {status}, {body}"); |
| 101 |
} |
| 102 |
|
| 103 |
|
| 104 |
pub(super) async fn json_response<T: serde::de::DeserializeOwned>( |
| 105 |
resp: reqwest::Response, |
| 106 |
context: &str, |
| 107 |
) -> anyhow::Result<T> { |
| 108 |
Ok(bail_for_status(resp, context).await?.json().await?) |
| 109 |
} |
| 110 |
|
| 111 |
|
| 112 |
pub(super) async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> { |
| 113 |
bail_for_status(resp, context).await?; |
| 114 |
Ok(()) |
| 115 |
} |
| 116 |
|
| 117 |
|
| 118 |
#[derive(Clone)] |
| 119 |
pub(crate) struct MnwApiClient { |
| 120 |
http: reqwest::Client, |
| 121 |
base_url: String, |
| 122 |
service_token: String, |
| 123 |
|
| 124 |
|
| 125 |
actor_token: Option<String>, |
| 126 |
} |
| 127 |
|
| 128 |
impl MnwApiClient { |
| 129 |
pub(crate) fn new(base_url: String, service_token: String) -> Self { |
| 130 |
let http = crate::tls::builder() |
| 131 |
.timeout(std::time::Duration::from_secs(5)) |
| 132 |
.build() |
| 133 |
.expect("failed to build HTTP client"); |
| 134 |
|
| 135 |
Self { |
| 136 |
http, |
| 137 |
base_url, |
| 138 |
service_token, |
| 139 |
actor_token: None, |
| 140 |
} |
| 141 |
} |
| 142 |
|
| 143 |
|
| 144 |
|
| 145 |
pub(crate) fn set_actor_token(&mut self, token: String) { |
| 146 |
self.actor_token = Some(token); |
| 147 |
} |
| 148 |
|
| 149 |
|
| 150 |
fn actor_header(&self) -> &str { |
| 151 |
self.actor_token.as_deref().unwrap_or("") |
| 152 |
} |
| 153 |
|
| 154 |
|
| 155 |
|
| 156 |
pub(crate) async fn lookup_ssh_key( |
| 157 |
&self, |
| 158 |
fingerprint: &str, |
| 159 |
) -> anyhow::Result<Option<UserInfo>> { |
| 160 |
let url = format!("{}/api/internal/ssh-key-lookup", self.base_url); |
| 161 |
let resp = self |
| 162 |
.http |
| 163 |
.get(&url) |
| 164 |
.bearer_auth(&self.service_token) |
| 165 |
.header("X-MNW-Actor", self.actor_header()) |
| 166 |
.query(&[("fingerprint", fingerprint)]) |
| 167 |
.send() |
| 168 |
.await?; |
| 169 |
|
| 170 |
if resp.status() == reqwest::StatusCode::NOT_FOUND { |
| 171 |
return Ok(None); |
| 172 |
} |
| 173 |
|
| 174 |
if !resp.status().is_success() { |
| 175 |
anyhow::bail!("SSH key lookup failed: HTTP {}", resp.status()); |
| 176 |
} |
| 177 |
|
| 178 |
let info: UserInfo = resp.json().await?; |
| 179 |
Ok(Some(info)) |
| 180 |
} |
| 181 |
} |
| 182 |
|
| 183 |
#[cfg(test)] |
| 184 |
mod tests { |
| 185 |
use super::analytics::ProjectRevenue; |
| 186 |
use super::*; |
| 187 |
|
| 188 |
|
| 189 |
fn project_json(extra: &str) -> String { |
| 190 |
format!( |
| 191 |
r#"{{"id":"p1","slug":"s","title":"T","project_type":"music", |
| 192 |
"is_public":true,"item_count":2,"revenue_cents":90000{extra}}}"# |
| 193 |
) |
| 194 |
} |
| 195 |
|
| 196 |
#[test] |
| 197 |
fn a_project_renders_the_currency_the_server_named() { |
| 198 |
let p: Project = serde_json::from_str(&project_json( |
| 199 |
r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000}"#, |
| 200 |
)) |
| 201 |
.unwrap(); |
| 202 |
assert_eq!(p.currency, Currency::Gbp); |
| 203 |
assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00"); |
| 204 |
} |
| 205 |
|
| 206 |
#[test] |
| 207 |
fn a_project_spanning_two_currencies_shows_both() { |
| 208 |
|
| 209 |
let p: Project = serde_json::from_str(&project_json( |
| 210 |
r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000,"usd":12000}"#, |
| 211 |
)) |
| 212 |
.unwrap(); |
| 213 |
assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00 + $120.00"); |
| 214 |
assert_eq!( |
| 215 |
p.revenue().display_compact(Currency::Usd), |
| 216 |
"\u{a3}900.00 +1" |
| 217 |
); |
| 218 |
} |
| 219 |
|
| 220 |
#[test] |
| 221 |
fn a_response_without_the_currency_fields_still_parses_as_usd() { |
| 222 |
|
| 223 |
|
| 224 |
let p: Project = serde_json::from_str(&project_json("")).unwrap(); |
| 225 |
assert_eq!(p.currency, Currency::Usd); |
| 226 |
assert_eq!(p.revenue().display(Currency::Usd), "$900.00"); |
| 227 |
} |
| 228 |
|
| 229 |
#[test] |
| 230 |
fn a_project_with_no_sales_renders_zero_in_the_viewers_currency() { |
| 231 |
|
| 232 |
|
| 233 |
|
| 234 |
let p: Project = serde_json::from_str( |
| 235 |
r#"{"id":"p1","slug":"s","title":"T","project_type":"music","is_public":true, |
| 236 |
"item_count":0,"revenue_cents":0,"currency":"usd","revenue_cents_by_currency":{}}"#, |
| 237 |
) |
| 238 |
.unwrap(); |
| 239 |
assert_eq!(p.revenue().display(Currency::Gbp), "\u{a3}0"); |
| 240 |
assert_eq!(p.revenue().display_compact(Currency::Gbp), "\u{a3}0"); |
| 241 |
} |
| 242 |
|
| 243 |
#[test] |
| 244 |
fn the_login_lookup_carries_the_viewers_currency() { |
| 245 |
let u: UserInfo = serde_json::from_str( |
| 246 |
r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":"basic", |
| 247 |
"can_create_projects":true,"suspended":false,"actor_token":"t", |
| 248 |
"settlement_currency":"cad"}"#, |
| 249 |
) |
| 250 |
.unwrap(); |
| 251 |
assert_eq!(u.settlement_currency, Currency::Cad); |
| 252 |
} |
| 253 |
|
| 254 |
#[test] |
| 255 |
fn a_login_lookup_without_the_field_defaults_to_usd() { |
| 256 |
let u: UserInfo = serde_json::from_str( |
| 257 |
r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":null, |
| 258 |
"can_create_projects":true,"suspended":false,"actor_token":"t"}"#, |
| 259 |
) |
| 260 |
.unwrap(); |
| 261 |
assert_eq!(u.settlement_currency, Currency::Usd); |
| 262 |
} |
| 263 |
|
| 264 |
#[test] |
| 265 |
fn top_project_revenue_reads_the_same_contract() { |
| 266 |
let p: ProjectRevenue = serde_json::from_str( |
| 267 |
r#"{"id":"p1","title":"T","revenue_cents":5000,"currency":"nzd", |
| 268 |
"revenue_cents_by_currency":{"nzd":5000}}"#, |
| 269 |
) |
| 270 |
.unwrap(); |
| 271 |
assert_eq!(p.revenue().display(Currency::Usd), "NZ$50.00"); |
| 272 |
} |
| 273 |
} |
| 274 |
|