max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
9 files changed,
+1986 insertions,
-500 deletions
| @@ -1,1934 +1,0 @@ | |||
| 1 | - | //! HTTP client for the MNW internal API. | |
| 2 | - | ||
| 3 | - | use std::collections::BTreeMap; | |
| 4 | - | ||
| 5 | - | use serde::{Deserialize, Serialize}; | |
| 6 | - | ||
| 7 | - | use crate::currency::{Currency, RevenueByCurrency}; | |
| 8 | - | ||
| 9 | - | /// A repository as `repo list` renders it. | |
| 10 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 11 | - | pub(crate) struct CliRepo { | |
| 12 | - | pub name: String, | |
| 13 | - | pub visibility: String, | |
| 14 | - | pub description: String, | |
| 15 | - | pub created_at: String, | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | /// A repository plus its issue counts, for `repo info`. | |
| 19 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 20 | - | pub(crate) struct CliRepoInfo { | |
| 21 | - | pub name: String, | |
| 22 | - | pub visibility: String, | |
| 23 | - | pub description: String, | |
| 24 | - | pub created_at: String, | |
| 25 | - | pub open_issues: i64, | |
| 26 | - | pub closed_issues: i64, | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | /// An SSH key as `key list` renders it. | |
| 30 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 31 | - | pub(crate) struct CliSshKey { | |
| 32 | - | pub fingerprint: String, | |
| 33 | - | pub label: String, | |
| 34 | - | pub created_at: String, | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | /// User info returned from the SSH key lookup endpoint. | |
| 38 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 39 | - | pub(crate) struct UserInfo { | |
| 40 | - | pub user_id: String, | |
| 41 | - | pub username: String, | |
| 42 | - | pub display_name: Option<String>, | |
| 43 | - | pub creator_tier: Option<String>, | |
| 44 | - | pub can_create_projects: bool, | |
| 45 | - | pub suspended: bool, | |
| 46 | - | /// Signed actor assertion the server mints at lookup; forwarded as | |
| 47 | - | /// `X-MNW-Actor` on internal calls so the server derives identity from an | |
| 48 | - | /// SSH-authenticated token rather than a caller-supplied `user_id`. | |
| 49 | - | #[serde(default)] | |
| 50 | - | pub actor_token: String, | |
| 51 | - | /// The currency this creator is paid in. Every amount that is theirs — | |
| 52 | - | /// their prices, their period totals — renders in it. Defaulted for the | |
| 53 | - | /// window where a new CLI talks to a server that predates the field. | |
| 54 | - | #[serde(default)] | |
| 55 | - | pub settlement_currency: Currency, | |
| 56 | - | /// The theme this creator picked, as `makeover::ThemeSelection` encodes it: | |
| 57 | - | /// a theme id, or `"system"` to follow the terminal. | |
| 58 | - | /// | |
| 59 | - | /// `None` where the server did not send the field, which is every server | |
| 60 | - | /// today — it is not stored yet, and adding it is filed as its own task. | |
| 61 | - | /// Absent and unset are the same thing until then, and both follow the | |
| 62 | - | /// terminal, so this reads correctly on both sides of that change. | |
| 63 | - | #[serde(default)] | |
| 64 | - | pub theme_id: Option<String>, | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | /// A creator's project with item count and revenue. | |
| 68 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 69 | - | #[allow( | |
| 70 | - | clippy::struct_field_names, | |
| 71 | - | reason = "project_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" | |
| 72 | - | )] | |
| 73 | - | pub(crate) struct Project { | |
| 74 | - | pub id: String, | |
| 75 | - | pub slug: String, | |
| 76 | - | pub title: String, | |
| 77 | - | pub project_type: String, | |
| 78 | - | pub is_public: bool, | |
| 79 | - | pub item_count: i64, | |
| 80 | - | /// Revenue in `currency` — the project's largest single-currency total, not | |
| 81 | - | /// a sum across currencies. Only meaningful next to `currency`. | |
| 82 | - | pub revenue_cents: i64, | |
| 83 | - | /// The currency `revenue_cents` is denominated in. A project can earn in a | |
| 84 | - | /// currency that is not the viewer's: revenue splits are paid in the | |
| 85 | - | /// currency of the project that generated them. | |
| 86 | - | #[serde(default)] | |
| 87 | - | pub currency: Currency, | |
| 88 | - | /// Every currency this project earned in, keyed by lowercase ISO code. | |
| 89 | - | /// Normally one entry matching `revenue_cents`; empty from a server that | |
| 90 | - | /// predates the field. | |
| 91 | - | #[serde(default)] | |
| 92 | - | pub revenue_cents_by_currency: BTreeMap<String, i64>, | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | impl Project { | |
| 96 | - | /// Revenue across every currency it was earned in. | |
| 97 | - | pub(crate) fn revenue(&self) -> RevenueByCurrency { | |
| 98 | - | revenue_of( | |
| 99 | - | self.revenue_cents, | |
| 100 | - | self.currency, | |
| 101 | - | &self.revenue_cents_by_currency, | |
| 102 | - | ) | |
| 103 | - | } | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | /// Read a revenue figure that arrives as both a dominant amount and a full | |
| 107 | - | /// per-currency map. | |
| 108 | - | /// | |
| 109 | - | /// The map is authoritative when present. It is empty in two cases that must | |
| 110 | - | /// not render blank: a server too old to send it, and a project with no sales. | |
| 111 | - | /// Both fall back to the single pair. | |
| 112 | - | /// | |
| 113 | - | /// A zero amount then reduces to nothing, and the render falls through to the | |
| 114 | - | /// viewer's own currency. That is the right symbol for it: with no sales there | |
| 115 | - | /// is no currency the money is *in*, and the `currency` the server names for an | |
| 116 | - | /// empty total is its own default rather than a fact about the project. | |
| 117 | - | fn revenue_of( | |
| 118 | - | cents: i64, | |
| 119 | - | currency: Currency, | |
| 120 | - | by_currency: &BTreeMap<String, i64>, | |
| 121 | - | ) -> RevenueByCurrency { | |
| 122 | - | if by_currency.is_empty() { | |
| 123 | - | RevenueByCurrency::from_rows([(currency, cents)]) | |
| 124 | - | } else { | |
| 125 | - | RevenueByCurrency::from_wire_map(by_currency) | |
| 126 | - | } | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | /// An item within a project. | |
| 130 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 131 | - | #[allow( | |
| 132 | - | clippy::struct_field_names, | |
| 133 | - | reason = "item_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" | |
| 134 | - | )] | |
| 135 | - | pub(crate) struct Item { | |
| 136 | - | pub id: String, | |
| 137 | - | pub title: String, | |
| 138 | - | pub item_type: String, | |
| 139 | - | pub price_cents: i32, | |
| 140 | - | pub is_public: bool, | |
| 141 | - | pub sort_order: i32, | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | /// Period comparison stats for the creator. | |
| 145 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 146 | - | pub(crate) struct CreatorStats { | |
| 147 | - | pub current_revenue_cents: i64, | |
| 148 | - | pub previous_revenue_cents: i64, | |
| 149 | - | pub current_sales: i64, | |
| 150 | - | pub previous_sales: i64, | |
| 151 | - | pub current_followers: i64, | |
| 152 | - | pub previous_followers: i64, | |
| 153 | - | pub total_projects: i64, | |
| 154 | - | pub total_items: i64, | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | /// Response from the create-item internal endpoint. | |
| 158 | - | #[derive(Debug, Deserialize)] | |
| 159 | - | #[allow(dead_code)] | |
| 160 | - | pub(crate) struct ItemCreated { | |
| 161 | - | pub item_id: String, | |
| 162 | - | pub project_id: String, | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | /// Response from the presign-upload internal endpoint. | |
| 166 | - | #[derive(Debug, Deserialize)] | |
| 167 | - | #[allow(dead_code)] | |
| 168 | - | pub(crate) struct PresignResponse { | |
| 169 | - | pub upload_url: String, | |
| 170 | - | pub s3_key: String, | |
| 171 | - | pub expires_in: u64, | |
| 172 | - | pub cache_control: Option<String>, | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | /// Above this size an upload goes through a multipart session instead of one | |
| 176 | - | /// presigned PUT. The single-PUT path reads the whole file into memory, which is | |
| 177 | - | /// fine for a small file and unacceptable for a multi-GB one; the multipart path | |
| 178 | - | /// holds one part at a time. It is also the only path that can carry a file past | |
| 179 | - | /// S3's 5 GiB single-PUT ceiling, which is what the tier limits allow for. | |
| 180 | - | pub(crate) const MULTIPART_THRESHOLD_BYTES: u64 = 64 * 1024 * 1024; | |
| 181 | - | ||
| 182 | - | /// How many presigned part URLs to request at a time. Must not exceed the | |
| 183 | - | /// server's own window cap. | |
| 184 | - | const PART_URL_WINDOW: u32 = 100; | |
| 185 | - | ||
| 186 | - | /// An opened multipart upload session. | |
| 187 | - | #[derive(Debug, Clone, Deserialize)] | |
| 188 | - | pub(crate) struct MultipartStart { | |
| 189 | - | pub upload_id: String, | |
| 190 | - | pub s3_key: String, | |
| 191 | - | pub part_size: u64, | |
| 192 | - | pub part_count: u32, | |
| 193 | - | pub expires_in: u64, | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | /// One presigned part target, with the exact length the signature binds. | |
| 197 | - | #[derive(Debug, Clone, Deserialize)] | |
| 198 | - | pub(crate) struct MultipartPartUrl { | |
| 199 | - | pub part_number: i32, | |
| 200 | - | pub content_length: u64, | |
| 201 | - | pub url: String, | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | #[derive(Debug, Deserialize)] | |
| 205 | - | struct MultipartPartsResponse { | |
| 206 | - | parts: Vec<MultipartPartUrl>, | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | /// Full item detail returned from the get/update endpoints. | |
| 210 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 211 | - | pub(crate) struct ItemDetail { | |
| 212 | - | pub id: String, | |
| 213 | - | pub title: String, | |
| 214 | - | pub description: Option<String>, | |
| 215 | - | pub price_cents: i32, | |
| 216 | - | pub item_type: String, | |
| 217 | - | pub is_public: bool, | |
| 218 | - | pub slug: String, | |
| 219 | - | pub sort_order: i32, | |
| 220 | - | pub sales_count: i32, | |
| 221 | - | pub download_count: i32, | |
| 222 | - | pub play_count: i32, | |
| 223 | - | pub pwyw_enabled: bool, | |
| 224 | - | pub pwyw_min_cents: Option<i32>, | |
| 225 | - | pub has_audio: bool, | |
| 226 | - | pub has_cover: bool, | |
| 227 | - | pub created_at: String, | |
| 228 | - | pub updated_at: String, | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | /// A version of an item. | |
| 232 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 233 | - | #[allow( | |
| 234 | - | clippy::struct_field_names, | |
| 235 | - | reason = "version_number mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" | |
| 236 | - | )] | |
| 237 | - | pub(crate) struct Version { | |
| 238 | - | pub id: String, | |
| 239 | - | pub version_number: String, | |
| 240 | - | pub changelog: Option<String>, | |
| 241 | - | pub file_name: Option<String>, | |
| 242 | - | pub file_size_bytes: Option<i64>, | |
| 243 | - | pub download_count: i32, | |
| 244 | - | pub is_current: bool, | |
| 245 | - | pub created_at: String, | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | /// A blog post summary. | |
| 249 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 250 | - | pub(crate) struct BlogPost { | |
| 251 | - | pub id: String, | |
| 252 | - | pub title: String, | |
| 253 | - | pub slug: String, | |
| 254 | - | pub is_published: bool, | |
| 255 | - | pub publish_at: Option<String>, | |
| 256 | - | pub created_at: String, | |
| 257 | - | pub updated_at: String, | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | /// A promo code. | |
| 261 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 262 | - | pub(crate) struct PromoCode { | |
| 263 | - | pub id: String, | |
| 264 | - | pub code: String, | |
| 265 | - | pub code_purpose: String, | |
| 266 | - | pub discount_type: Option<String>, | |
| 267 | - | pub discount_value: Option<i32>, | |
| 268 | - | pub item_title: Option<String>, | |
| 269 | - | pub project_title: Option<String>, | |
| 270 | - | pub max_uses: Option<i32>, | |
| 271 | - | pub use_count: i32, | |
| 272 | - | pub created_at: String, | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | /// A license key. | |
| 276 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 277 | - | pub(crate) struct LicenseKey { | |
| 278 | - | pub id: String, | |
| 279 | - | pub key_code: String, | |
| 280 | - | pub activation_count: i32, | |
| 281 | - | pub max_activations: Option<i32>, | |
| 282 | - | pub is_revoked: bool, | |
| 283 | - | pub created_at: String, | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | /// Response from the storage-info internal endpoint. | |
| 287 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 288 | - | pub(crate) struct StorageInfo { | |
| 289 | - | pub storage_used_bytes: i64, | |
| 290 | - | pub max_storage_bytes: i64, | |
| 291 | - | pub allows_file_uploads: bool, | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | /// A revenue bucket for analytics timeseries. | |
| 295 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 296 | - | pub(crate) struct AnalyticsBucket { | |
| 297 | - | pub label: String, | |
| 298 | - | pub revenue_cents: i64, | |
| 299 | - | pub sales_count: i64, | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | /// Per-project revenue summary. | |
| 303 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 304 | - | pub(crate) struct ProjectRevenue { | |
| 305 | - | pub id: String, | |
| 306 | - | pub title: String, | |
| 307 | - | /// Revenue in `currency`. See [`Project::revenue_cents`]. | |
| 308 | - | pub revenue_cents: i64, | |
| 309 | - | #[serde(default)] | |
| 310 | - | pub currency: Currency, | |
| 311 | - | #[serde(default)] | |
| 312 | - | pub revenue_cents_by_currency: BTreeMap<String, i64>, | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | impl ProjectRevenue { | |
| 316 | - | /// Revenue across every currency it was earned in. | |
| 317 | - | pub(crate) fn revenue(&self) -> RevenueByCurrency { | |
| 318 | - | revenue_of( | |
| 319 | - | self.revenue_cents, | |
| 320 | - | self.currency, | |
| 321 | - | &self.revenue_cents_by_currency, | |
| 322 | - | ) | |
| 323 | - | } | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | /// Analytics response with timeseries, comparison, and top projects. | |
| 327 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 328 | - | pub(crate) struct AnalyticsData { | |
| 329 | - | pub buckets: Vec<AnalyticsBucket>, | |
| 330 | - | pub current_revenue_cents: i64, | |
| 331 | - | pub previous_revenue_cents: i64, | |
| 332 | - | pub current_sales: i64, | |
| 333 | - | pub previous_sales: i64, | |
| 334 | - | pub current_followers: i64, | |
| 335 | - | pub previous_followers: i64, | |
| 336 | - | pub top_projects: Vec<ProjectRevenue>, | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | /// A seller transaction. | |
| 340 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 341 | - | pub(crate) struct Transaction { | |
| 342 | - | pub id: String, | |
| 343 | - | pub item_title: Option<String>, | |
| 344 | - | pub amount_cents: i32, | |
| 345 | - | pub status: String, | |
| 346 | - | pub created_at: String, | |
| 347 | - | pub completed_at: Option<String>, | |
| 348 | - | } | |
| 349 | - | ||
| 350 | - | /// CSV export result. | |
| 351 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 352 | - | pub(crate) struct ExportResult { | |
| 353 | - | pub csv: String, | |
| 354 | - | pub row_count: usize, | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | /// A registered SSH key. | |
| 358 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 359 | - | pub(crate) struct SshKeyInfo { | |
| 360 | - | pub id: String, | |
| 361 | - | pub label: String, | |
| 362 | - | pub fingerprint: String, | |
| 363 | - | pub created_at: String, | |
| 364 | - | } | |
| 365 | - | ||
| 366 | - | /// A tag on an item or from search. | |
| 367 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 368 | - | pub(crate) struct TagInfo { | |
| 369 | - | pub id: String, | |
| 370 | - | pub name: String, | |
| 371 | - | pub slug: String, | |
| 372 | - | pub is_primary: bool, | |
| 373 | - | } | |
| 374 | - | ||
| 375 | - | /// Result of a broadcast send. | |
| 376 | - | #[derive(Debug, Deserialize)] | |
| 377 | - | #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not | |
| 378 | - | pub(crate) struct BroadcastResult { | |
| 379 | - | pub success: bool, | |
| 380 | - | pub recipient_count: usize, | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | /// A subscription tier. | |
| 384 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 385 | - | pub(crate) struct TierInfo { | |
| 386 | - | pub id: String, | |
| 387 | - | pub name: String, | |
| 388 | - | pub description: String, | |
| 389 | - | pub price_cents: i32, | |
| 390 | - | pub is_active: bool, | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | /// A collection. | |
| 394 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 395 | - | pub(crate) struct CollectionInfo { | |
| 396 | - | pub id: String, | |
| 397 | - | pub slug: String, | |
| 398 | - | pub title: String, | |
| 399 | - | pub description: String, | |
| 400 | - | pub is_public: bool, | |
| 401 | - | pub item_count: i64, | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | /// Custom domain info. | |
| 405 | - | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 406 | - | pub(crate) struct DomainInfo { | |
| 407 | - | pub id: String, | |
| 408 | - | pub domain: String, | |
| 409 | - | pub verified: bool, | |
| 410 | - | pub verification_token: String, | |
| 411 | - | pub instructions: Option<String>, | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | /// Domain verification result. | |
| 415 | - | #[derive(Debug, Deserialize)] | |
| 416 | - | #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not | |
| 417 | - | pub(crate) struct DomainVerifyResult { | |
| 418 | - | pub verified: bool, | |
| 419 | - | pub message: String, | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | /// Response from the git authorize endpoint. | |
| 423 | - | #[derive(Debug, Deserialize)] | |
| 424 | - | pub(crate) struct GitAuthResponse { | |
| 425 | - | pub repo_path: String, | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | /// Check response status and deserialize JSON body, or bail with error details. | |
| 429 | - | async fn json_response<T: serde::de::DeserializeOwned>( | |
| 430 | - | resp: reqwest::Response, | |
| 431 | - | context: &str, | |
| 432 | - | ) -> anyhow::Result<T> { | |
| 433 | - | if !resp.status().is_success() { | |
| 434 | - | let status = resp.status(); | |
| 435 | - | let body = resp.text().await.unwrap_or_else(|e| { | |
| 436 | - | tracing::warn!(error = %e, %context, "failed to read error response body"); | |
| 437 | - | String::new() | |
| 438 | - | }); | |
| 439 | - | if body.is_empty() { | |
| 440 | - | anyhow::bail!("{context} failed: HTTP {status}"); | |
| 441 | - | } | |
| 442 | - | anyhow::bail!("{context} failed: HTTP {status}, {body}"); | |
| 443 | - | } | |
| 444 | - | Ok(resp.json().await?) | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | /// Check response status for success, or bail with error details. | |
| 448 | - | async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> { | |
| 449 | - | if !resp.status().is_success() { | |
| 450 | - | let status = resp.status(); | |
| 451 | - | let body = resp.text().await.unwrap_or_else(|e| { | |
| 452 | - | tracing::warn!(error = %e, %context, "failed to read error response body"); | |
| 453 | - | String::new() | |
| 454 | - | }); | |
| 455 | - | if body.is_empty() { | |
| 456 | - | anyhow::bail!("{context} failed: HTTP {status}"); | |
| 457 | - | } | |
| 458 | - | anyhow::bail!("{context} failed: HTTP {status}, {body}"); | |
| 459 | - | } | |
| 460 | - | Ok(()) | |
| 461 | - | } | |
| 462 | - | ||
| 463 | - | /// Client for calling MNW internal API endpoints. | |
| 464 | - | #[derive(Clone)] | |
| 465 | - | pub(crate) struct MnwApiClient { | |
| 466 | - | http: reqwest::Client, | |
| 467 | - | base_url: String, | |
| 468 | - | service_token: String, | |
| 469 | - | /// Set once per session from the SSH-key-lookup response; forwarded on | |
| 470 | - | /// internal creator calls as `X-MNW-Actor`. | |
| 471 | - | actor_token: Option<String>, | |
| 472 | - | } | |
| 473 | - | ||
| 474 | - | impl MnwApiClient { | |
| 475 | - | pub(crate) fn new(base_url: String, service_token: String) -> Self { | |
| 476 | - | let http = crate::tls::builder() | |
| 477 | - | .timeout(std::time::Duration::from_secs(5)) | |
| 478 | - | .build() | |
| 479 | - | .expect("failed to build HTTP client"); | |
| 480 | - | ||
| 481 | - | Self { | |
| 482 | - | http, | |
| 483 | - | base_url, | |
| 484 | - | service_token, | |
| 485 | - | actor_token: None, | |
| 486 | - | } | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | /// Record the actor assertion for the authenticated session. Subsequent | |
| 490 | - | /// internal calls forward it so the server can verify the acting identity. | |
| 491 | - | pub(crate) fn set_actor_token(&mut self, token: String) { | |
| 492 | - | self.actor_token = Some(token); | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | /// The `X-MNW-Actor` header value for internal calls (empty before lookup). | |
| 496 | - | fn actor_header(&self) -> &str { | |
| 497 | - | self.actor_token.as_deref().unwrap_or("") | |
| 498 | - | } | |
| 499 | - | ||
| 500 | - | /// Look up a user by SSH key fingerprint. |
Lines truncated
| @@ -1,0 +1,121 @@ | |||
| 1 | + | //! Analytics: the timeseries and period comparison behind the dashboard, the | |
| 2 | + | //! transaction ledger, and the CSV export of it. | |
| 3 | + | ||
| 4 | + | use super::{MnwApiClient, json_response, revenue_of}; | |
| 5 | + | use crate::currency::{Currency, RevenueByCurrency}; | |
| 6 | + | use serde::{Deserialize, Serialize}; | |
| 7 | + | use std::collections::BTreeMap; | |
| 8 | + | ||
| 9 | + | /// A revenue bucket for analytics timeseries. | |
| 10 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 11 | + | pub(crate) struct AnalyticsBucket { | |
| 12 | + | pub label: String, | |
| 13 | + | pub revenue_cents: i64, | |
| 14 | + | pub sales_count: i64, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /// Per-project revenue summary. | |
| 18 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 19 | + | pub(crate) struct ProjectRevenue { | |
| 20 | + | pub id: String, | |
| 21 | + | pub title: String, | |
| 22 | + | /// Revenue in `currency`. See [`Project::revenue_cents`]. | |
| 23 | + | pub revenue_cents: i64, | |
| 24 | + | #[serde(default)] | |
| 25 | + | pub currency: Currency, | |
| 26 | + | #[serde(default)] | |
| 27 | + | pub revenue_cents_by_currency: BTreeMap<String, i64>, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl ProjectRevenue { | |
| 31 | + | /// Revenue across every currency it was earned in. | |
| 32 | + | pub(crate) fn revenue(&self) -> RevenueByCurrency { | |
| 33 | + | revenue_of( | |
| 34 | + | self.revenue_cents, | |
| 35 | + | self.currency, | |
| 36 | + | &self.revenue_cents_by_currency, | |
| 37 | + | ) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Analytics response with timeseries, comparison, and top projects. | |
| 42 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 43 | + | pub(crate) struct AnalyticsData { | |
| 44 | + | pub buckets: Vec<AnalyticsBucket>, | |
| 45 | + | pub current_revenue_cents: i64, | |
| 46 | + | pub previous_revenue_cents: i64, | |
| 47 | + | pub current_sales: i64, | |
| 48 | + | pub previous_sales: i64, | |
| 49 | + | pub current_followers: i64, | |
| 50 | + | pub previous_followers: i64, | |
| 51 | + | pub top_projects: Vec<ProjectRevenue>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// A seller transaction. | |
| 55 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 56 | + | pub(crate) struct Transaction { | |
| 57 | + | pub id: String, | |
| 58 | + | pub item_title: Option<String>, | |
| 59 | + | pub amount_cents: i32, | |
| 60 | + | pub status: String, | |
| 61 | + | pub created_at: String, | |
| 62 | + | pub completed_at: Option<String>, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// CSV export result. | |
| 66 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 67 | + | pub(crate) struct ExportResult { | |
| 68 | + | pub csv: String, | |
| 69 | + | pub row_count: usize, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | impl MnwApiClient { | |
| 73 | + | /// Get analytics data (timeseries, period comparison, top projects). | |
| 74 | + | pub(crate) async fn get_analytics( | |
| 75 | + | &self, | |
| 76 | + | user_id: &str, | |
| 77 | + | range: &str, | |
| 78 | + | ) -> anyhow::Result<AnalyticsData> { | |
| 79 | + | let url = format!("{}/api/internal/creator/analytics", self.base_url); | |
| 80 | + | let resp = self | |
| 81 | + | .http | |
| 82 | + | .get(&url) | |
| 83 | + | .bearer_auth(&self.service_token) | |
| 84 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 85 | + | .query(&[("user_id", user_id), ("range", range)]) | |
| 86 | + | .send() | |
| 87 | + | .await?; | |
| 88 | + | ||
| 89 | + | json_response(resp, "get_analytics").await | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// Get recent seller transactions. | |
| 93 | + | pub(crate) async fn get_transactions(&self, user_id: &str) -> anyhow::Result<Vec<Transaction>> { | |
| 94 | + | let url = format!("{}/api/internal/creator/transactions", self.base_url); | |
| 95 | + | let resp = self | |
| 96 | + | .http | |
| 97 | + | .get(&url) | |
| 98 | + | .bearer_auth(&self.service_token) | |
| 99 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 100 | + | .query(&[("user_id", user_id)]) | |
| 101 | + | .send() | |
| 102 | + | .await?; | |
| 103 | + | ||
| 104 | + | json_response(resp, "get_transactions").await | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// Export sales as CSV string. | |
| 108 | + | pub(crate) async fn export_sales_csv(&self, user_id: &str) -> anyhow::Result<ExportResult> { | |
| 109 | + | let url = format!("{}/api/internal/creator/export/sales", self.base_url); | |
| 110 | + | let resp = self | |
| 111 | + | .http | |
| 112 | + | .get(&url) | |
| 113 | + | .bearer_auth(&self.service_token) | |
| 114 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 115 | + | .query(&[("user_id", user_id)]) | |
| 116 | + | .send() | |
| 117 | + | .await?; | |
| 118 | + | ||
| 119 | + | json_response(resp, "export_sales_csv").await | |
| 120 | + | } | |
| 121 | + | } |
| @@ -1,0 +1,84 @@ | |||
| 1 | + | //! Custom domains: attach one to a creator, prove ownership, take it away. | |
| 2 | + | ||
| 3 | + | use super::{MnwApiClient, empty_response, json_response}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | ||
| 6 | + | /// Custom domain info. | |
| 7 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 8 | + | pub(crate) struct DomainInfo { | |
| 9 | + | pub id: String, | |
| 10 | + | pub domain: String, | |
| 11 | + | pub verified: bool, | |
| 12 | + | pub verification_token: String, | |
| 13 | + | pub instructions: Option<String>, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// Domain verification result. | |
| 17 | + | #[derive(Debug, Deserialize)] | |
| 18 | + | #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not | |
| 19 | + | pub(crate) struct DomainVerifyResult { | |
| 20 | + | pub verified: bool, | |
| 21 | + | pub message: String, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl MnwApiClient { | |
| 25 | + | pub(crate) async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> { | |
| 26 | + | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 27 | + | let resp = self | |
| 28 | + | .http | |
| 29 | + | .get(&url) | |
| 30 | + | .bearer_auth(&self.service_token) | |
| 31 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 32 | + | .query(&[("user_id", user_id)]) | |
| 33 | + | .send() | |
| 34 | + | .await?; | |
| 35 | + | let val: serde_json::Value = json_response(resp, "get_domain").await?; | |
| 36 | + | if val.is_null() { | |
| 37 | + | return Ok(None); | |
| 38 | + | } | |
| 39 | + | Ok(serde_json::from_value(val).ok()) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | pub(crate) async fn add_domain( | |
| 43 | + | &self, | |
| 44 | + | user_id: &str, | |
| 45 | + | domain: &str, | |
| 46 | + | ) -> anyhow::Result<DomainInfo> { | |
| 47 | + | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 48 | + | let resp = self | |
| 49 | + | .http | |
| 50 | + | .post(&url) | |
| 51 | + | .bearer_auth(&self.service_token) | |
| 52 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 53 | + | .json(&serde_json::json!({"user_id": user_id, "domain": domain})) | |
| 54 | + | .send() | |
| 55 | + | .await?; | |
| 56 | + | json_response(resp, "add_domain").await | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | pub(crate) async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> { | |
| 60 | + | let url = format!("{}/api/internal/creator/domain/verify", self.base_url); | |
| 61 | + | let resp = self | |
| 62 | + | .http | |
| 63 | + | .post(&url) | |
| 64 | + | .bearer_auth(&self.service_token) | |
| 65 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 66 | + | .query(&[("user_id", user_id)]) | |
| 67 | + | .send() | |
| 68 | + | .await?; | |
| 69 | + | json_response(resp, "verify_domain").await | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | pub(crate) async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> { | |
| 73 | + | let url = format!("{}/api/internal/creator/domain", self.base_url); | |
| 74 | + | let resp = self | |
| 75 | + | .http | |
| 76 | + | .delete(&url) | |
| 77 | + | .bearer_auth(&self.service_token) | |
| 78 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 79 | + | .query(&[("user_id", user_id)]) | |
| 80 | + | .send() | |
| 81 | + | .await?; | |
| 82 | + | empty_response(resp, "remove_domain").await | |
| 83 | + | } | |
| 84 | + | } |
| @@ -1,0 +1,220 @@ | |||
| 1 | + | //! Git repositories and the SSH keys that reach them. | |
| 2 | + | //! | |
| 3 | + | //! Addressed by repo NAME, matching the CLI's own vocabulary. The browser API | |
| 4 | + | //! keys on the repo id because a page has the row loaded; a person at a | |
| 5 | + | //! terminal does not. | |
| 6 | + | ||
| 7 | + | use super::{MnwApiClient, empty_response, json_response}; | |
| 8 | + | use serde::{Deserialize, Serialize}; | |
| 9 | + | ||
| 10 | + | /// A repository as `repo list` renders it. | |
| 11 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 12 | + | pub(crate) struct CliRepo { | |
| 13 | + | pub name: String, | |
| 14 | + | pub visibility: String, | |
| 15 | + | pub description: String, | |
| 16 | + | pub created_at: String, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// A repository plus its issue counts, for `repo info`. | |
| 20 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 21 | + | pub(crate) struct CliRepoInfo { | |
| 22 | + | pub name: String, | |
| 23 | + | pub visibility: String, | |
| 24 | + | pub description: String, | |
| 25 | + | pub created_at: String, | |
| 26 | + | pub open_issues: i64, | |
| 27 | + | pub closed_issues: i64, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// An SSH key as `key list` renders it. | |
| 31 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 32 | + | pub(crate) struct CliSshKey { | |
| 33 | + | pub fingerprint: String, | |
| 34 | + | pub label: String, | |
| 35 | + | pub created_at: String, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// A registered SSH key. | |
| 39 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 40 | + | pub(crate) struct SshKeyInfo { | |
| 41 | + | pub id: String, | |
| 42 | + | pub label: String, | |
| 43 | + | pub fingerprint: String, | |
| 44 | + | pub created_at: String, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Response from the git authorize endpoint. | |
| 48 | + | #[derive(Debug, Deserialize)] | |
| 49 | + | pub(crate) struct GitAuthResponse { | |
| 50 | + | pub repo_path: String, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | impl MnwApiClient { | |
| 54 | + | /// Authorize a git operation and get the on-disk repo path. | |
| 55 | + | pub(crate) async fn git_authorize( | |
| 56 | + | &self, | |
| 57 | + | user_id: &str, | |
| 58 | + | operation: &str, | |
| 59 | + | owner: &str, | |
| 60 | + | repo_name: &str, | |
| 61 | + | ) -> anyhow::Result<GitAuthResponse> { | |
| 62 | + | let url = format!("{}/api/internal/git/authorize", self.base_url); | |
| 63 | + | let resp = self | |
| 64 | + | .http | |
| 65 | + | .post(&url) | |
| 66 | + | .bearer_auth(&self.service_token) | |
| 67 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 68 | + | .json(&serde_json::json!({ | |
| 69 | + | "user_id": user_id, | |
| 70 | + | "operation": operation, | |
| 71 | + | "owner": owner, | |
| 72 | + | "repo_name": repo_name, | |
| 73 | + | })) | |
| 74 | + | .send() | |
| 75 | + | .await?; | |
| 76 | + | ||
| 77 | + | if !resp.status().is_success() { | |
| 78 | + | let status = resp.status(); | |
| 79 | + | let body = resp.text().await.unwrap_or_else(|e| { | |
| 80 | + | tracing::warn!(error = %e, "failed to read git_authorize error body"); | |
| 81 | + | String::new() | |
| 82 | + | }); | |
| 83 | + | // Parse JSON error if available, fall back to status text | |
| 84 | + | let msg = serde_json::from_str::<serde_json::Value>(&body) | |
| 85 | + | .ok() | |
| 86 | + | .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) | |
| 87 | + | .unwrap_or_else(|| format!("HTTP {status}")); | |
| 88 | + | anyhow::bail!("{msg}"); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | Ok(resp.json().await?) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// List registered SSH keys for a user. | |
| 95 | + | pub(crate) async fn list_ssh_keys(&self, user_id: &str) -> anyhow::Result<Vec<SshKeyInfo>> { | |
| 96 | + | let url = format!("{}/api/internal/creator/ssh-keys", self.base_url); | |
| 97 | + | let resp = self | |
| 98 | + | .http | |
| 99 | + | .get(&url) | |
| 100 | + | .bearer_auth(&self.service_token) | |
| 101 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 102 | + | .query(&[("user_id", user_id)]) | |
| 103 | + | .send() | |
| 104 | + | .await?; | |
| 105 | + | ||
| 106 | + | json_response(resp, "list_ssh_keys").await | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | pub(crate) async fn repo_list(&self, user_id: &str) -> anyhow::Result<Vec<CliRepo>> { | |
| 110 | + | let url = format!("{}/api/internal/creator/repos", self.base_url); | |
| 111 | + | let resp = self | |
| 112 | + | .http | |
| 113 | + | .get(&url) | |
| 114 | + | .bearer_auth(&self.service_token) | |
| 115 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 116 | + | .query(&[("user_id", user_id)]) | |
| 117 | + | .send() | |
| 118 | + | .await?; | |
| 119 | + | json_response(resp, "repo_list").await | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | pub(crate) async fn repo_info(&self, user_id: &str, name: &str) -> anyhow::Result<CliRepoInfo> { | |
| 123 | + | let url = format!("{}/api/internal/creator/repos/{name}", self.base_url); | |
| 124 | + | let resp = self | |
| 125 | + | .http | |
| 126 | + | .get(&url) | |
| 127 | + | .bearer_auth(&self.service_token) | |
| 128 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 129 | + | .query(&[("user_id", user_id)]) | |
| 130 | + | .send() | |
| 131 | + | .await?; | |
| 132 | + | json_response(resp, "repo_info").await | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | pub(crate) async fn repo_set_visibility( | |
| 136 | + | &self, | |
| 137 | + | user_id: &str, | |
| 138 | + | name: &str, | |
| 139 | + | visibility: &str, | |
| 140 | + | ) -> anyhow::Result<()> { | |
| 141 | + | let url = format!( | |
| 142 | + | "{}/api/internal/creator/repos/{name}/visibility", | |
| 143 | + | self.base_url | |
| 144 | + | ); | |
| 145 | + | let resp = self | |
| 146 | + | .http | |
| 147 | + | .put(&url) | |
| 148 | + | .bearer_auth(&self.service_token) | |
| 149 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 150 | + | .query(&[("user_id", user_id)]) | |
| 151 | + | .json(&serde_json::json!({ "visibility": visibility })) | |
| 152 | + | .send() | |
| 153 | + | .await?; | |
| 154 | + | empty_response(resp, "repo_set_visibility").await | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | pub(crate) async fn repo_set_description( | |
| 158 | + | &self, | |
| 159 | + | user_id: &str, | |
| 160 | + | name: &str, | |
| 161 | + | description: &str, | |
| 162 | + | ) -> anyhow::Result<()> { | |
| 163 | + | let url = format!( | |
| 164 | + | "{}/api/internal/creator/repos/{name}/description", | |
| 165 | + | self.base_url | |
| 166 | + | ); | |
| 167 | + | let resp = self | |
| 168 | + | .http | |
| 169 | + | .put(&url) | |
| 170 | + | .bearer_auth(&self.service_token) | |
| 171 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 172 | + | .query(&[("user_id", user_id)]) | |
| 173 | + | .json(&serde_json::json!({ "description": description })) | |
| 174 | + | .send() | |
| 175 | + | .await?; | |
| 176 | + | empty_response(resp, "repo_set_description").await | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | pub(crate) async fn repo_delete(&self, user_id: &str, name: &str) -> anyhow::Result<()> { | |
| 180 | + | let url = format!("{}/api/internal/creator/repos/{name}", self.base_url); | |
| 181 | + | let resp = self | |
| 182 | + | .http | |
| 183 | + | .delete(&url) | |
| 184 | + | .bearer_auth(&self.service_token) | |
| 185 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 186 | + | .query(&[("user_id", user_id)]) | |
| 187 | + | .send() | |
| 188 | + | .await?; | |
| 189 | + | empty_response(resp, "repo_delete").await | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | pub(crate) async fn key_list(&self, user_id: &str) -> anyhow::Result<Vec<CliSshKey>> { | |
| 193 | + | let url = format!("{}/api/internal/creator/ssh-keys", self.base_url); | |
| 194 | + | let resp = self | |
| 195 | + | .http | |
| 196 | + | .get(&url) | |
| 197 | + | .bearer_auth(&self.service_token) | |
| 198 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 199 | + | .query(&[("user_id", user_id)]) | |
| 200 | + | .send() | |
| 201 | + | .await?; | |
| 202 | + | json_response(resp, "key_list").await | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | pub(crate) async fn key_remove(&self, user_id: &str, fingerprint: &str) -> anyhow::Result<()> { | |
| 206 | + | let url = format!( | |
| 207 | + | "{}/api/internal/creator/ssh-keys/{fingerprint}", | |
| 208 | + | self.base_url | |
| 209 | + | ); | |
| 210 | + | let resp = self | |
| 211 | + | .http | |
| 212 | + | .delete(&url) | |
| 213 | + | .bearer_auth(&self.service_token) | |
| 214 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 215 | + | .query(&[("user_id", user_id)]) | |
| 216 | + | .send() | |
| 217 | + | .await?; | |
| 218 | + | empty_response(resp, "key_remove").await | |
| 219 | + | } | |
| 220 | + | } |
| @@ -1,0 +1,299 @@ | |||
| 1 | + | //! Items: create, read, update, publish, and the tags an item carries. | |
| 2 | + | ||
| 3 | + | use super::{MnwApiClient, empty_response, json_response}; | |
| 4 | + | use serde::{Deserialize, Serialize}; | |
| 5 | + | ||
| 6 | + | /// Response from the create-item internal endpoint. | |
| 7 | + | #[derive(Debug, Deserialize)] | |
| 8 | + | #[allow(dead_code)] | |
| 9 | + | pub(crate) struct ItemCreated { | |
| 10 | + | pub item_id: String, | |
| 11 | + | pub project_id: String, | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | /// Full item detail returned from the get/update endpoints. | |
| 15 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 16 | + | pub(crate) struct ItemDetail { | |
| 17 | + | pub id: String, | |
| 18 | + | pub title: String, | |
| 19 | + | pub description: Option<String>, | |
| 20 | + | pub price_cents: i32, | |
| 21 | + | pub item_type: String, | |
| 22 | + | pub is_public: bool, | |
| 23 | + | pub slug: String, | |
| 24 | + | pub sort_order: i32, | |
| 25 | + | pub sales_count: i32, | |
| 26 | + | pub download_count: i32, | |
| 27 | + | pub play_count: i32, | |
| 28 | + | pub pwyw_enabled: bool, | |
| 29 | + | pub pwyw_min_cents: Option<i32>, | |
| 30 | + | pub has_audio: bool, | |
| 31 | + | pub has_cover: bool, | |
| 32 | + | pub created_at: String, | |
| 33 | + | pub updated_at: String, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// A version of an item. | |
| 37 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 38 | + | #[allow( | |
| 39 | + | clippy::struct_field_names, | |
| 40 | + | reason = "version_number mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" | |
| 41 | + | )] | |
| 42 | + | pub(crate) struct Version { | |
| 43 | + | pub id: String, | |
| 44 | + | pub version_number: String, | |
| 45 | + | pub changelog: Option<String>, | |
| 46 | + | pub file_name: Option<String>, | |
| 47 | + | pub file_size_bytes: Option<i64>, | |
| 48 | + | pub download_count: i32, | |
| 49 | + | pub is_current: bool, | |
| 50 | + | pub created_at: String, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// A tag on an item or from search. | |
| 54 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 55 | + | pub(crate) struct TagInfo { | |
| 56 | + | pub id: String, | |
| 57 | + | pub name: String, | |
| 58 | + | pub slug: String, | |
| 59 | + | pub is_primary: bool, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | impl MnwApiClient { | |
| 63 | + | /// Create an item in a project. | |
| 64 | + | pub(crate) async fn create_item( | |
| 65 | + | &self, | |
| 66 | + | user_id: &str, | |
| 67 | + | project_id: &str, | |
| 68 | + | title: &str, | |
| 69 | + | item_type: &str, | |
| 70 | + | price_cents: i32, | |
| 71 | + | ) -> anyhow::Result<ItemCreated> { | |
| 72 | + | let url = format!("{}/api/internal/creator/items", self.base_url); | |
| 73 | + | let resp = self | |
| 74 | + | .http | |
| 75 | + | .post(&url) | |
| 76 | + | .bearer_auth(&self.service_token) | |
| 77 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 78 | + | .json(&serde_json::json!({ | |
| 79 | + | "user_id": user_id, | |
| 80 | + | "project_id": project_id, | |
| 81 | + | "title": title, | |
| 82 | + | "item_type": item_type, | |
| 83 | + | "price_cents": price_cents, | |
| 84 | + | })) | |
| 85 | + | .send() | |
| 86 | + | .await?; | |
| 87 | + | ||
| 88 | + | json_response(resp, "create_item").await | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Fetch full item detail. | |
| 92 | + | pub(crate) async fn get_item_detail( | |
| 93 | + | &self, | |
| 94 | + | user_id: &str, | |
| 95 | + | item_id: &str, | |
| 96 | + | ) -> anyhow::Result<ItemDetail> { | |
| 97 | + | let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); | |
| 98 | + | let resp = self | |
| 99 | + | .http | |
| 100 | + | .get(&url) | |
| 101 | + | .bearer_auth(&self.service_token) | |
| 102 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 103 | + | .query(&[("user_id", user_id)]) | |
| 104 | + | .send() | |
| 105 | + | .await?; | |
| 106 | + | ||
| 107 | + | json_response(resp, "get_item_detail").await | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Update item fields. Only non-None fields are changed. | |
| 111 | + | pub(crate) async fn update_item( | |
| 112 | + | &self, | |
| 113 | + | user_id: &str, | |
| 114 | + | item_id: &str, | |
| 115 | + | title: Option<&str>, | |
| 116 | + | description: Option<&str>, | |
| 117 | + | price_cents: Option<i32>, | |
| 118 | + | is_public: Option<bool>, | |
| 119 | + | ) -> anyhow::Result<ItemDetail> { | |
| 120 | + | let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); | |
| 121 | + | let mut body = serde_json::json!({ "user_id": user_id }); | |
| 122 | + | if let Some(t) = title { | |
| 123 | + | body["title"] = serde_json::Value::String(t.to_string()); | |
| 124 | + | } | |
| 125 | + | if let Some(d) = description { | |
| 126 | + | body["description"] = serde_json::Value::String(d.to_string()); | |
| 127 | + | } | |
| 128 | + | if let Some(p) = price_cents { | |
| 129 | + | body["price_cents"] = serde_json::json!(p); | |
| 130 | + | } | |
| 131 | + | if let Some(v) = is_public { | |
| 132 | + | body["is_public"] = serde_json::json!(v); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | let resp = self | |
| 136 | + | .http | |
| 137 | + | .put(&url) | |
| 138 | + | .bearer_auth(&self.service_token) | |
| 139 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 140 | + | .json(&body) | |
| 141 | + | .send() | |
| 142 | + | .await?; | |
| 143 | + | ||
| 144 | + | json_response(resp, "update_item").await | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Delete an item permanently. | |
| 148 | + | pub(crate) async fn delete_item(&self, user_id: &str, item_id: &str) -> anyhow::Result<()> { | |
| 149 | + | let url = format!("{}/api/internal/creator/items/{}", self.base_url, item_id); | |
| 150 | + | let resp = self | |
| 151 | + | .http | |
| 152 | + | .delete(&url) | |
| 153 | + | .bearer_auth(&self.service_token) | |
| 154 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 155 | + | .query(&[("user_id", user_id)]) | |
| 156 | + | .send() | |
| 157 | + | .await?; | |
| 158 | + | ||
| 159 | + | empty_response(resp, "delete_item").await | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Publish an item (set is_public=true). | |
| 163 | + | pub(crate) async fn publish_item( | |
| 164 | + | &self, | |
| 165 | + | user_id: &str, | |
| 166 | + | item_id: &str, | |
| 167 | + | ) -> anyhow::Result<ItemDetail> { | |
| 168 | + | let url = format!( | |
| 169 | + | "{}/api/internal/creator/items/{}/publish", | |
| 170 | + | self.base_url, item_id | |
| 171 | + | ); | |
| 172 | + | let resp = self | |
| 173 | + | .http | |
| 174 | + | .post(&url) | |
| 175 | + | .bearer_auth(&self.service_token) | |
| 176 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 177 | + | .json(&serde_json::json!({ "user_id": user_id })) | |
| 178 | + | .send() | |
| 179 | + | .await?; | |
| 180 | + | ||
| 181 | + | json_response(resp, "publish_item").await | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Unpublish an item (set is_public=false). | |
| 185 | + | pub(crate) async fn unpublish_item( | |
| 186 | + | &self, | |
| 187 | + | user_id: &str, | |
| 188 | + | item_id: &str, | |
| 189 | + | ) -> anyhow::Result<ItemDetail> { | |
| 190 | + | let url = format!( | |
| 191 | + | "{}/api/internal/creator/items/{}/unpublish", | |
| 192 | + | self.base_url, item_id | |
| 193 | + | ); | |
| 194 | + | let resp = self | |
| 195 | + | .http | |
| 196 | + | .post(&url) | |
| 197 | + | .bearer_auth(&self.service_token) | |
| 198 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 199 | + | .json(&serde_json::json!({ "user_id": user_id })) | |
| 200 | + | .send() | |
| 201 | + | .await?; | |
| 202 | + | ||
| 203 | + | json_response(resp, "unpublish_item").await | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Fetch versions for an item. | |
| 207 | + | pub(crate) async fn get_item_versions( | |
| 208 | + | &self, | |
| 209 | + | user_id: &str, | |
| 210 | + | item_id: &str, | |
| 211 | + | ) -> anyhow::Result<Vec<Version>> { | |
| 212 | + | let url = format!( | |
| 213 | + | "{}/api/internal/creator/items/{}/versions", | |
| 214 | + | self.base_url, item_id | |
| 215 | + | ); | |
| 216 | + | let resp = self | |
| 217 | + | .http | |
| 218 | + | .get(&url) | |
| 219 | + | .bearer_auth(&self.service_token) | |
| 220 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 221 | + | .query(&[("user_id", user_id)]) | |
| 222 | + | .send() | |
| 223 | + | .await?; | |
| 224 | + | ||
| 225 | + | json_response(resp, "get_item_versions").await | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | pub(crate) async fn list_item_tags( | |
| 229 | + | &self, | |
| 230 | + | user_id: &str, | |
| 231 | + | item_id: &str, | |
| 232 | + | ) -> anyhow::Result<Vec<TagInfo>> { | |
| 233 | + | let url = format!( | |
| 234 | + | "{}/api/internal/creator/items/{}/tags", | |
| 235 | + | self.base_url, item_id | |
| 236 | + | ); | |
| 237 | + | let resp = self | |
| 238 | + | .http | |
| 239 | + | .get(&url) | |
| 240 | + | .bearer_auth(&self.service_token) | |
| 241 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 242 | + | .query(&[("user_id", user_id)]) | |
| 243 | + | .send() | |
| 244 | + | .await?; | |
| 245 | + | json_response(resp, "list_item_tags").await | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | pub(crate) async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> { | |
| 249 | + | let url = format!("{}/api/internal/tags/search", self.base_url); | |
| 250 | + | let resp = self | |
| 251 | + | .http | |
| 252 | + | .get(&url) | |
| 253 | + | .bearer_auth(&self.service_token) | |
| 254 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 255 | + | .query(&[("q", query)]) | |
| 256 | + | .send() | |
| 257 | + | .await?; | |
| 258 | + | json_response(resp, "search_tags").await | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | pub(crate) async fn add_item_tag( | |
| 262 | + | &self, | |
| 263 | + | user_id: &str, | |
| 264 | + | item_id: &str, | |
| 265 | + | tag_id: &str, | |
| 266 | + | ) -> anyhow::Result<()> { | |
| 267 | + | let url = format!("{}/api/internal/creator/items/tags", self.base_url); | |
| 268 | + | let resp = self | |
| 269 | + | .http | |
| 270 | + | .post(&url) | |
| 271 | + | .bearer_auth(&self.service_token) | |
| 272 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 273 | + | .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) | |
| 274 | + | .send() | |
| 275 | + | .await?; | |
| 276 | + | empty_response(resp, "add_item_tag").await | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // Unused by the TUI today; kept so the client mirrors the full | |
| 280 | + | // /api/internal surface rather than only the paths one caller happens to hit. | |
| 281 | + | #[allow(dead_code)] | |
| 282 | + | pub(crate) async fn remove_item_tag( | |
| 283 | + | &self, | |
| 284 | + | user_id: &str, | |
| 285 | + | item_id: &str, | |
| 286 | + | tag_id: &str, | |
| 287 | + | ) -> anyhow::Result<()> { | |
| 288 | + | let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url); | |
| 289 | + | let resp = self | |
| 290 | + | .http | |
| 291 | + | .post(&url) | |
| 292 | + | .bearer_auth(&self.service_token) | |
| 293 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 294 | + | .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id})) | |
| 295 | + | .send() | |
| 296 | + | .await?; | |
| 297 | + | empty_response(resp, "remove_item_tag").await | |
| 298 | + | } | |
| 299 | + | } |
| @@ -1,0 +1,273 @@ | |||
| 1 | + | //! HTTP client for the MNW internal API. | |
| 2 | + | //! | |
| 3 | + | //! One client, one method per endpoint, split into a module per endpoint | |
| 4 | + | //! family. Each family adds its own `impl MnwApiClient` block: a descendant | |
| 5 | + | //! module sees the parent's private fields, so the token and base-URL state | |
| 6 | + | //! stays private to this module and every family still reaches it. | |
| 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 | + | /// User info returned from the SSH key lookup endpoint. | |
| 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 | + | /// Signed actor assertion the server mints at lookup; forwarded as | |
| 38 | + | /// `X-MNW-Actor` on internal calls so the server derives identity from an | |
| 39 | + | /// SSH-authenticated token rather than a caller-supplied `user_id`. | |
| 40 | + | #[serde(default)] | |
| 41 | + | pub actor_token: String, | |
| 42 | + | /// The currency this creator is paid in. Every amount that is theirs — | |
| 43 | + | /// their prices, their period totals — renders in it. Defaulted for the | |
| 44 | + | /// window where a new CLI talks to a server that predates the field. | |
| 45 | + | #[serde(default)] | |
| 46 | + | pub settlement_currency: Currency, | |
| 47 | + | /// The theme this creator picked, as `makeover::ThemeSelection` encodes it: | |
| 48 | + | /// a theme id, or `"system"` to follow the terminal. | |
| 49 | + | /// | |
| 50 | + | /// `None` where the server did not send the field, which is every server | |
| 51 | + | /// today — it is not stored yet, and adding it is filed as its own task. | |
| 52 | + | /// Absent and unset are the same thing until then, and both follow the | |
| 53 | + | /// terminal, so this reads correctly on both sides of that change. | |
| 54 | + | #[serde(default)] | |
| 55 | + | pub theme_id: Option<String>, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Read a revenue figure that arrives as both a dominant amount and a full | |
| 59 | + | /// per-currency map. | |
| 60 | + | /// | |
| 61 | + | /// The map is authoritative when present. It is empty in two cases that must | |
| 62 | + | /// not render blank: a server too old to send it, and a project with no sales. | |
| 63 | + | /// Both fall back to the single pair. | |
| 64 | + | /// | |
| 65 | + | /// A zero amount then reduces to nothing, and the render falls through to the | |
| 66 | + | /// viewer's own currency. That is the right symbol for it: with no sales there | |
| 67 | + | /// is no currency the money is *in*, and the `currency` the server names for an | |
| 68 | + | /// empty total is its own default rather than a fact about the project. | |
| 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 | + | /// Bail with the server's own error detail unless the response succeeded. | |
| 82 | + | /// | |
| 83 | + | /// The body is worth the extra read: the API answers a rejected request with a | |
| 84 | + | /// reason, and without this the operator sees a bare status code. | |
| 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 | + | /// Check response status and deserialize JSON body, or bail with error details. | |
| 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 | + | /// Check response status for success, or bail with error details. | |
| 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 | + | /// Client for calling MNW internal API endpoints. | |
| 118 | + | #[derive(Clone)] | |
| 119 | + | pub(crate) struct MnwApiClient { | |
| 120 | + | http: reqwest::Client, | |
| 121 | + | base_url: String, | |
| 122 | + | service_token: String, | |
| 123 | + | /// Set once per session from the SSH-key-lookup response; forwarded on | |
| 124 | + | /// internal creator calls as `X-MNW-Actor`. | |
| 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 | + | /// Record the actor assertion for the authenticated session. Subsequent | |
| 144 | + | /// internal calls forward it so the server can verify the acting identity. | |
| 145 | + | pub(crate) fn set_actor_token(&mut self, token: String) { | |
| 146 | + | self.actor_token = Some(token); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// The `X-MNW-Actor` header value for internal calls (empty before lookup). | |
| 150 | + | fn actor_header(&self) -> &str { | |
| 151 | + | self.actor_token.as_deref().unwrap_or("") | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// Look up a user by SSH key fingerprint. | |
| 155 | + | /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found. | |
| 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 | + | /// The shape `/api/internal/creator/projects` sends today. | |
| 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 | + | // The whole point of the task: never one of them, never their sum. | |
| 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 | + | // A new CLI against a server that predates the settlement-currency pass | |
| 223 | + | // must render exactly what it always did, not fail to load the screen. | |
| 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 | + | // An empty cell here would read as "no data" rather than "no revenue". | |
| 232 | + | // The `currency` the server names on an empty total is its own default, | |
| 233 | + | // so the viewer's own is what the zero renders in. | |
| 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 | + | } |
| @@ -1,0 +1,184 @@ | |||
| 1 | + | //! Projects: the creator's top-level containers, their item lists, and the | |
| 2 | + | //! storage and revenue totals the dashboard reads. | |
| 3 | + | ||
| 4 | + | use super::{MnwApiClient, json_response, revenue_of}; | |
| 5 | + | use crate::currency::{Currency, RevenueByCurrency}; | |
| 6 | + | use serde::{Deserialize, Serialize}; | |
| 7 | + | use std::collections::BTreeMap; | |
| 8 | + | ||
| 9 | + | /// A creator's project with item count and revenue. | |
| 10 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 11 | + | #[allow( | |
| 12 | + | clippy::struct_field_names, | |
| 13 | + | reason = "project_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" | |
| 14 | + | )] | |
| 15 | + | pub(crate) struct Project { | |
| 16 | + | pub id: String, | |
| 17 | + | pub slug: String, | |
| 18 | + | pub title: String, | |
| 19 | + | pub project_type: String, | |
| 20 | + | pub is_public: bool, | |
| 21 | + | pub item_count: i64, | |
| 22 | + | /// Revenue in `currency` — the project's largest single-currency total, not | |
| 23 | + | /// a sum across currencies. Only meaningful next to `currency`. | |
| 24 | + | pub revenue_cents: i64, | |
| 25 | + | /// The currency `revenue_cents` is denominated in. A project can earn in a | |
| 26 | + | /// currency that is not the viewer's: revenue splits are paid in the | |
| 27 | + | /// currency of the project that generated them. | |
| 28 | + | #[serde(default)] | |
| 29 | + | pub currency: Currency, | |
| 30 | + | /// Every currency this project earned in, keyed by lowercase ISO code. | |
| 31 | + | /// Normally one entry matching `revenue_cents`; empty from a server that | |
| 32 | + | /// predates the field. | |
| 33 | + | #[serde(default)] | |
| 34 | + | pub revenue_cents_by_currency: BTreeMap<String, i64>, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | impl Project { | |
| 38 | + | /// Revenue across every currency it was earned in. | |
| 39 | + | pub(crate) fn revenue(&self) -> RevenueByCurrency { | |
| 40 | + | revenue_of( | |
| 41 | + | self.revenue_cents, | |
| 42 | + | self.currency, | |
| 43 | + | &self.revenue_cents_by_currency, | |
| 44 | + | ) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// An item within a project. | |
| 49 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 50 | + | #[allow( | |
| 51 | + | clippy::struct_field_names, | |
| 52 | + | reason = "item_type mirrors the server JSON/DB schema; renaming would diverge the field from the wire contract" | |
| 53 | + | )] | |
| 54 | + | pub(crate) struct Item { | |
| 55 | + | pub id: String, | |
| 56 | + | pub title: String, | |
| 57 | + | pub item_type: String, | |
| 58 | + | pub price_cents: i32, | |
| 59 | + | pub is_public: bool, | |
| 60 | + | pub sort_order: i32, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Period comparison stats for the creator. | |
| 64 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 65 | + | pub(crate) struct CreatorStats { | |
| 66 | + | pub current_revenue_cents: i64, | |
| 67 | + | pub previous_revenue_cents: i64, | |
| 68 | + | pub current_sales: i64, | |
| 69 | + | pub previous_sales: i64, | |
| 70 | + | pub current_followers: i64, | |
| 71 | + | pub previous_followers: i64, | |
| 72 | + | pub total_projects: i64, | |
| 73 | + | pub total_items: i64, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Response from the storage-info internal endpoint. | |
| 77 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 78 | + | pub(crate) struct StorageInfo { | |
| 79 | + | pub storage_used_bytes: i64, | |
| 80 | + | pub max_storage_bytes: i64, | |
| 81 | + | pub allows_file_uploads: bool, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | impl MnwApiClient { | |
| 85 | + | /// Fetch all projects for a creator with item counts and revenue. | |
| 86 | + | pub(crate) async fn get_projects(&self, user_id: &str) -> anyhow::Result<Vec<Project>> { | |
| 87 | + | let url = format!("{}/api/internal/creator/projects", self.base_url); | |
| 88 | + | let resp = self | |
| 89 | + | .http | |
| 90 | + | .get(&url) | |
| 91 | + | .bearer_auth(&self.service_token) | |
| 92 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 93 | + | .query(&[("user_id", user_id)]) | |
| 94 | + | .send() | |
| 95 | + | .await?; | |
| 96 | + | ||
| 97 | + | json_response(resp, "get_projects").await | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Create a new project. | |
| 101 | + | pub(crate) async fn create_project( | |
| 102 | + | &self, | |
| 103 | + | user_id: &str, | |
| 104 | + | title: &str, | |
| 105 | + | project_type: &str, | |
| 106 | + | description: Option<&str>, | |
| 107 | + | ) -> anyhow::Result<Project> { | |
| 108 | + | let url = format!("{}/api/internal/creator/projects", self.base_url); | |
| 109 | + | let mut body = serde_json::json!({ | |
| 110 | + | "user_id": user_id, | |
| 111 | + | "title": title, | |
| 112 | + | "project_type": project_type, | |
| 113 | + | }); | |
| 114 | + | if let Some(desc) = description { | |
| 115 | + | body["description"] = serde_json::Value::String(desc.to_string()); | |
| 116 | + | } | |
| 117 | + | let resp = self | |
| 118 | + | .http | |
| 119 | + | .post(&url) | |
| 120 | + | .bearer_auth(&self.service_token) | |
| 121 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 122 | + | .json(&body) | |
| 123 | + | .send() | |
| 124 | + | .await?; | |
| 125 | + | ||
| 126 | + | json_response(resp, "create_project").await | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Fetch items in a project. | |
| 130 | + | pub(crate) async fn get_project_items( | |
| 131 | + | &self, | |
| 132 | + | project_id: &str, | |
| 133 | + | user_id: &str, | |
| 134 | + | ) -> anyhow::Result<Vec<Item>> { | |
| 135 | + | let url = format!( | |
| 136 | + | "{}/api/internal/creator/projects/{}/items", | |
| 137 | + | self.base_url, project_id | |
| 138 | + | ); | |
| 139 | + | let resp = self | |
| 140 | + | .http | |
| 141 | + | .get(&url) | |
| 142 | + | .bearer_auth(&self.service_token) | |
| 143 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 144 | + | .query(&[("user_id", user_id)]) | |
| 145 | + | .send() | |
| 146 | + | .await?; | |
| 147 | + | ||
| 148 | + | json_response(resp, "get_project_items").await | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Fetch period comparison stats for a creator. | |
| 152 | + | pub(crate) async fn get_stats( | |
| 153 | + | &self, | |
| 154 | + | user_id: &str, | |
| 155 | + | range: &str, | |
| 156 | + | ) -> anyhow::Result<CreatorStats> { | |
| 157 | + | let url = format!("{}/api/internal/creator/stats", self.base_url); | |
| 158 | + | let resp = self | |
| 159 | + | .http | |
| 160 | + | .get(&url) | |
| 161 | + | .bearer_auth(&self.service_token) | |
| 162 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 163 | + | .query(&[("user_id", user_id), ("range", range)]) | |
| 164 | + | .send() | |
| 165 | + | .await?; | |
| 166 | + | ||
| 167 | + | json_response(resp, "get_stats").await | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Fetch storage usage and limits for a creator. | |
| 171 | + | pub(crate) async fn get_storage_info(&self, user_id: &str) -> anyhow::Result<StorageInfo> { | |
| 172 | + | let url = format!("{}/api/internal/creator/storage", self.base_url); | |
| 173 | + | let resp = self | |
| 174 | + | .http | |
| 175 | + | .get(&url) | |
| 176 | + | .bearer_auth(&self.service_token) | |
| 177 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 178 | + | .query(&[("user_id", user_id)]) | |
| 179 | + | .send() | |
| 180 | + | .await?; | |
| 181 | + | ||
| 182 | + | json_response(resp, "get_storage_info").await | |
| 183 | + | } | |
| 184 | + | } |
| @@ -1,0 +1,383 @@ | |||
| 1 | + | //! The storefront around the files: blog posts, promo codes, license keys, | |
| 2 | + | //! subscription tiers, collections, and the broadcast that tells buyers about | |
| 3 | + | //! any of it. | |
| 4 | + | ||
| 5 | + | use super::{MnwApiClient, empty_response, json_response}; | |
| 6 | + | use serde::{Deserialize, Serialize}; | |
| 7 | + | ||
| 8 | + | /// A blog post summary. | |
| 9 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 10 | + | pub(crate) struct BlogPost { | |
| 11 | + | pub id: String, | |
| 12 | + | pub title: String, | |
| 13 | + | pub slug: String, | |
| 14 | + | pub is_published: bool, | |
| 15 | + | pub publish_at: Option<String>, | |
| 16 | + | pub created_at: String, | |
| 17 | + | pub updated_at: String, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /// A promo code. | |
| 21 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 22 | + | pub(crate) struct PromoCode { | |
| 23 | + | pub id: String, | |
| 24 | + | pub code: String, | |
| 25 | + | pub code_purpose: String, | |
| 26 | + | pub discount_type: Option<String>, | |
| 27 | + | pub discount_value: Option<i32>, | |
| 28 | + | pub item_title: Option<String>, | |
| 29 | + | pub project_title: Option<String>, | |
| 30 | + | pub max_uses: Option<i32>, | |
| 31 | + | pub use_count: i32, | |
| 32 | + | pub created_at: String, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// A license key. | |
| 36 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 37 | + | pub(crate) struct LicenseKey { | |
| 38 | + | pub id: String, | |
| 39 | + | pub key_code: String, | |
| 40 | + | pub activation_count: i32, | |
| 41 | + | pub max_activations: Option<i32>, | |
| 42 | + | pub is_revoked: bool, | |
| 43 | + | pub created_at: String, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Result of a broadcast send. | |
| 47 | + | #[derive(Debug, Deserialize)] | |
| 48 | + | #[allow(dead_code)] // wire contract: every field the endpoint returns, read or not | |
| 49 | + | pub(crate) struct BroadcastResult { | |
| 50 | + | pub success: bool, | |
| 51 | + | pub recipient_count: usize, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// A subscription tier. | |
| 55 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 56 | + | pub(crate) struct TierInfo { | |
| 57 | + | pub id: String, | |
| 58 | + | pub name: String, | |
| 59 | + | pub description: String, | |
| 60 | + | pub price_cents: i32, | |
| 61 | + | pub is_active: bool, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// A collection. | |
| 65 | + | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 66 | + | pub(crate) struct CollectionInfo { | |
| 67 | + | pub id: String, | |
| 68 | + | pub slug: String, | |
| 69 | + | pub title: String, | |
| 70 | + | pub description: String, | |
| 71 | + | pub is_public: bool, | |
| 72 | + | pub item_count: i64, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | impl MnwApiClient { | |
| 76 | + | /// List blog posts for a project. | |
| 77 | + | pub(crate) async fn list_blog_posts( | |
| 78 | + | &self, | |
| 79 | + | user_id: &str, | |
| 80 | + | project_id: &str, | |
| 81 | + | ) -> anyhow::Result<Vec<BlogPost>> { | |
| 82 | + | let url = format!( | |
| 83 | + | "{}/api/internal/creator/projects/{}/blog", | |
| 84 | + | self.base_url, project_id | |
| 85 | + | ); | |
| 86 | + | let resp = self | |
| 87 | + | .http | |
| 88 | + | .get(&url) | |
| 89 | + | .bearer_auth(&self.service_token) | |
| 90 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 91 | + | .query(&[("user_id", user_id)]) | |
| 92 | + | .send() | |
| 93 | + | .await?; | |
| 94 | + | ||
| 95 | + | json_response(resp, "list_blog_posts").await | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Create a blog post, optionally scheduled for future publication. | |
| 99 | + | pub(crate) async fn create_blog_post( | |
| 100 | + | &self, | |
| 101 | + | user_id: &str, | |
| 102 | + | project_id: &str, | |
| 103 | + | title: &str, | |
| 104 | + | body_markdown: &str, | |
| 105 | + | publish: bool, | |
| 106 | + | publish_at: Option<&str>, | |
| 107 | + | ) -> anyhow::Result<BlogPost> { | |
| 108 | + | let url = format!("{}/api/internal/creator/blog", self.base_url); | |
| 109 | + | let mut body = serde_json::json!({ | |
| 110 | + | "user_id": user_id, | |
| 111 | + | "project_id": project_id, | |
| 112 | + | "title": title, | |
| 113 | + | "body_markdown": body_markdown, | |
| 114 | + | "publish": publish, | |
| 115 | + | }); | |
| 116 | + | if let Some(pa) = publish_at { | |
| 117 | + | body["publish_at"] = serde_json::Value::String(pa.to_string()); | |
| 118 | + | } | |
| 119 | + | let resp = self | |
| 120 | + | .http | |
| 121 | + | .post(&url) | |
| 122 | + | .bearer_auth(&self.service_token) | |
| 123 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 124 | + | .json(&body) | |
| 125 | + | .send() | |
| 126 | + | .await?; | |
| 127 | + | ||
| 128 | + | json_response(resp, "create_blog_post").await | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Delete a blog post. | |
| 132 | + | pub(crate) async fn delete_blog_post( | |
| 133 | + | &self, | |
| 134 | + | user_id: &str, | |
| 135 | + | post_id: &str, | |
| 136 | + | ) -> anyhow::Result<()> { | |
| 137 | + | let url = format!("{}/api/internal/creator/blog/{}", self.base_url, post_id); | |
| 138 | + | let resp = self | |
| 139 | + | .http | |
| 140 | + | .delete(&url) | |
| 141 | + | .bearer_auth(&self.service_token) | |
| 142 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 143 | + | .query(&[("user_id", user_id)]) | |
| 144 | + | .send() | |
| 145 | + | .await?; | |
| 146 | + | ||
| 147 | + | empty_response(resp, "delete_blog_post").await | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// List promo codes for a creator. | |
| 151 | + | pub(crate) async fn list_promo_codes(&self, user_id: &str) -> anyhow::Result<Vec<PromoCode>> { | |
| 152 | + | let url = format!("{}/api/internal/creator/promo-codes", self.base_url); | |
| 153 | + | let resp = self | |
| 154 | + | .http | |
| 155 | + | .get(&url) | |
| 156 | + | .bearer_auth(&self.service_token) | |
| 157 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 158 | + | .query(&[("user_id", user_id)]) | |
| 159 | + | .send() | |
| 160 | + | .await?; | |
| 161 | + | ||
| 162 | + | json_response(resp, "list_promo_codes").await | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// Create a promo code. | |
| 166 | + | pub(crate) async fn create_promo_code( | |
| 167 | + | &self, | |
| 168 | + | user_id: &str, | |
| 169 | + | code: &str, | |
| 170 | + | discount_type: &str, | |
| 171 | + | discount_value: i32, | |
| 172 | + | max_uses: Option<i32>, | |
| 173 | + | project_id: Option<&str>, | |
| 174 | + | ) -> anyhow::Result<PromoCode> { | |
| 175 | + | let url = format!("{}/api/internal/creator/promo-codes", self.base_url); | |
| 176 | + | let mut body = serde_json::json!({ | |
| 177 | + | "user_id": user_id, | |
| 178 | + | "code": code, | |
| 179 | + | "code_purpose": "discount", | |
| 180 | + | "discount_type": discount_type, | |
| 181 | + | "discount_value": discount_value, | |
| 182 | + | }); | |
| 183 | + | if let Some(max) = max_uses { | |
| 184 | + | body["max_uses"] = serde_json::json!(max); | |
| 185 | + | } | |
| 186 | + | if let Some(pid) = project_id { | |
| 187 | + | body["project_id"] = serde_json::json!(pid); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | let resp = self | |
| 191 | + | .http | |
| 192 | + | .post(&url) | |
| 193 | + | .bearer_auth(&self.service_token) | |
| 194 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 195 | + | .json(&body) | |
| 196 | + | .send() | |
| 197 | + | .await?; | |
| 198 | + | ||
| 199 | + | json_response(resp, "create_promo_code").await | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// Delete a promo code. | |
| 203 | + | pub(crate) async fn delete_promo_code( | |
| 204 | + | &self, | |
| 205 | + | user_id: &str, | |
| 206 | + | code_id: &str, | |
| 207 | + | ) -> anyhow::Result<()> { | |
| 208 | + | let url = format!( | |
| 209 | + | "{}/api/internal/creator/promo-codes/{}", | |
| 210 | + | self.base_url, code_id | |
| 211 | + | ); | |
| 212 | + | let resp = self | |
| 213 | + | .http | |
| 214 | + | .delete(&url) | |
| 215 | + | .bearer_auth(&self.service_token) | |
| 216 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 217 | + | .query(&[("user_id", user_id)]) | |
| 218 | + | .send() | |
| 219 | + | .await?; | |
| 220 | + | ||
| 221 | + | empty_response(resp, "delete_promo_code").await | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// List license keys for an item. | |
| 225 | + | pub(crate) async fn list_license_keys( | |
| 226 | + | &self, | |
| 227 | + | user_id: &str, | |
| 228 | + | item_id: &str, | |
| 229 | + | ) -> anyhow::Result<Vec<LicenseKey>> { | |
| 230 | + | let url = format!( | |
| 231 | + | "{}/api/internal/creator/items/{}/keys", | |
| 232 | + | self.base_url, item_id | |
| 233 | + | ); | |
| 234 | + | let resp = self | |
| 235 | + | .http | |
| 236 | + | .get(&url) | |
| 237 | + | .bearer_auth(&self.service_token) | |
| 238 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 239 | + | .query(&[("user_id", user_id)]) | |
| 240 | + | .send() | |
| 241 | + | .await?; | |
| 242 | + | ||
| 243 | + | json_response(resp, "list_license_keys").await | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// Generate a new license key for an item. | |
| 247 | + | pub(crate) async fn generate_license_key( | |
| 248 | + | &self, | |
| 249 | + | user_id: &str, | |
| 250 | + | item_id: &str, | |
| 251 | + | ) -> anyhow::Result<LicenseKey> { | |
| 252 | + | let url = format!( | |
| 253 | + | "{}/api/internal/creator/items/{}/keys", | |
| 254 | + | self.base_url, item_id | |
| 255 | + | ); | |
| 256 | + | let resp = self | |
| 257 | + | .http | |
| 258 | + | .post(&url) | |
| 259 | + | .bearer_auth(&self.service_token) | |
| 260 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 261 | + | .json(&serde_json::json!({ "user_id": user_id })) | |
| 262 | + | .send() | |
| 263 | + | .await?; | |
| 264 | + | ||
| 265 | + | json_response(resp, "generate_license_key").await | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Revoke a license key. | |
| 269 | + | pub(crate) async fn revoke_license_key( | |
| 270 | + | &self, | |
| 271 | + | user_id: &str, | |
| 272 | + | key_id: &str, | |
| 273 | + | ) -> anyhow::Result<()> { | |
| 274 | + | let url = format!( | |
| 275 | + | "{}/api/internal/creator/keys/{}/revoke", | |
| 276 | + | self.base_url, key_id | |
| 277 | + | ); | |
| 278 | + | let resp = self | |
| 279 | + | .http | |
| 280 | + | .post(&url) | |
| 281 | + | .bearer_auth(&self.service_token) | |
| 282 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 283 | + | .json(&serde_json::json!({ "user_id": user_id })) | |
| 284 | + | .send() | |
| 285 | + | .await?; | |
| 286 | + | ||
| 287 | + | empty_response(resp, "revoke_license_key").await | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | pub(crate) async fn send_broadcast( | |
| 291 | + | &self, | |
| 292 | + | user_id: &str, | |
| 293 | + | subject: &str, | |
| 294 | + | body: &str, | |
| 295 | + | ) -> anyhow::Result<BroadcastResult> { | |
| 296 | + | let url = format!("{}/api/internal/creator/broadcast", self.base_url); | |
| 297 | + | let resp = self | |
| 298 | + | .http | |
| 299 | + | .post(&url) | |
| 300 | + | .bearer_auth(&self.service_token) | |
| 301 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 302 | + | .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body})) | |
| 303 | + | .send() | |
| 304 | + | .await?; | |
| 305 | + | json_response(resp, "send_broadcast").await | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | pub(crate) async fn list_tiers( | |
| 309 | + | &self, | |
| 310 | + | user_id: &str, | |
| 311 | + | project_id: &str, | |
| 312 | + | ) -> anyhow::Result<Vec<TierInfo>> { | |
| 313 | + | let url = format!( | |
| 314 | + | "{}/api/internal/creator/projects/{}/tiers", | |
| 315 | + | self.base_url, project_id | |
| 316 | + | ); | |
| 317 | + | let resp = self | |
| 318 | + | .http | |
| 319 | + | .get(&url) | |
| 320 | + | .bearer_auth(&self.service_token) | |
| 321 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 322 | + | .query(&[("user_id", user_id)]) | |
| 323 | + | .send() | |
| 324 | + | .await?; | |
| 325 | + | json_response(resp, "list_tiers").await | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | pub(crate) async fn list_collections( | |
| 329 | + | &self, | |
| 330 | + | user_id: &str, | |
| 331 | + | ) -> anyhow::Result<Vec<CollectionInfo>> { | |
| 332 | + | let url = format!("{}/api/internal/creator/collections", self.base_url); | |
| 333 | + | let resp = self | |
| 334 | + | .http | |
| 335 | + | .get(&url) | |
| 336 | + | .bearer_auth(&self.service_token) | |
| 337 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 338 | + | .query(&[("user_id", user_id)]) | |
| 339 | + | .send() | |
| 340 | + | .await?; | |
| 341 | + | json_response(resp, "list_collections").await | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | #[allow(dead_code)] | |
| 345 | + | pub(crate) async fn create_collection( | |
| 346 | + | &self, | |
| 347 | + | user_id: &str, | |
| 348 | + | slug: &str, | |
| 349 | + | title: &str, | |
| 350 | + | ) -> anyhow::Result<serde_json::Value> { | |
| 351 | + | let url = format!("{}/api/internal/creator/collections", self.base_url); | |
| 352 | + | let resp = self | |
| 353 | + | .http | |
| 354 | + | .post(&url) | |
| 355 | + | .bearer_auth(&self.service_token) | |
| 356 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 357 | + | .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title})) | |
| 358 | + | .send() | |
| 359 | + | .await?; | |
| 360 | + | json_response(resp, "create_collection").await | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[allow(dead_code)] | |
| 364 | + | pub(crate) async fn delete_collection( | |
| 365 | + | &self, | |
| 366 | + | user_id: &str, | |
| 367 | + | collection_id: &str, | |
| 368 | + | ) -> anyhow::Result<()> { | |
| 369 | + | let url = format!( | |
| 370 | + | "{}/api/internal/creator/collections/{}", | |
| 371 | + | self.base_url, collection_id | |
| 372 | + | ); | |
| 373 | + | let resp = self | |
| 374 | + | .http | |
| 375 | + | .delete(&url) | |
| 376 | + | .bearer_auth(&self.service_token) | |
| 377 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 378 | + | .query(&[("user_id", user_id)]) | |
| 379 | + | .send() | |
| 380 | + | .await?; | |
| 381 | + | empty_response(resp, "delete_collection").await | |
| 382 | + | } | |
| 383 | + | } |
| @@ -1,0 +1,422 @@ | |||
| 1 | + | //! Getting bytes to S3. | |
| 2 | + | //! | |
| 3 | + | //! The one family here with real logic rather than transport boilerplate: a | |
| 4 | + | //! file over [`MULTIPART_THRESHOLD_BYTES`] goes up in parts, which means a part | |
| 5 | + | //! geometry from the server, a bounded set of presigned part URLs refreshed on | |
| 6 | + | //! a [`PART_URL_WINDOW`] cadence, and a completion call that names every ETag. | |
| 7 | + | ||
| 8 | + | use super::{MnwApiClient, json_response}; | |
| 9 | + | use serde::Deserialize; | |
| 10 | + | ||
| 11 | + | /// Response from the presign-upload internal endpoint. | |
| 12 | + | #[derive(Debug, Deserialize)] | |
| 13 | + | #[allow(dead_code)] | |
| 14 | + | pub(crate) struct PresignResponse { | |
| 15 | + | pub upload_url: String, | |
| 16 | + | pub s3_key: String, | |
| 17 | + | pub expires_in: u64, | |
| 18 | + | pub cache_control: Option<String>, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /// Above this size an upload goes through a multipart session instead of one | |
| 22 | + | /// presigned PUT. The single-PUT path reads the whole file into memory, which is | |
| 23 | + | /// fine for a small file and unacceptable for a multi-GB one; the multipart path | |
| 24 | + | /// holds one part at a time. It is also the only path that can carry a file past | |
| 25 | + | /// S3's 5 GiB single-PUT ceiling, which is what the tier limits allow for. | |
| 26 | + | pub(crate) const MULTIPART_THRESHOLD_BYTES: u64 = 64 * 1024 * 1024; | |
| 27 | + | ||
| 28 | + | /// How many presigned part URLs to request at a time. Must not exceed the | |
| 29 | + | /// server's own window cap. | |
| 30 | + | const PART_URL_WINDOW: u32 = 100; | |
| 31 | + | ||
| 32 | + | /// An opened multipart upload session. | |
| 33 | + | #[derive(Debug, Clone, Deserialize)] | |
| 34 | + | pub(crate) struct MultipartStart { | |
| 35 | + | pub upload_id: String, | |
| 36 | + | pub s3_key: String, | |
| 37 | + | pub part_size: u64, | |
| 38 | + | pub part_count: u32, | |
| 39 | + | pub expires_in: u64, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// One presigned part target, with the exact length the signature binds. | |
| 43 | + | #[derive(Debug, Clone, Deserialize)] | |
| 44 | + | pub(crate) struct MultipartPartUrl { | |
| 45 | + | pub part_number: i32, | |
| 46 | + | pub content_length: u64, | |
| 47 | + | pub url: String, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | #[derive(Debug, Deserialize)] | |
| 51 | + | struct MultipartPartsResponse { | |
| 52 | + | parts: Vec<MultipartPartUrl>, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | impl MnwApiClient { | |
| 56 | + | /// Get a presigned S3 upload URL. | |
| 57 | + | pub(crate) async fn presign_upload( | |
| 58 | + | &self, | |
| 59 | + | user_id: &str, | |
| 60 | + | item_id: &str, | |
| 61 | + | file_type: &str, | |
| 62 | + | file_name: &str, | |
| 63 | + | content_type: &str, | |
| 64 | + | ) -> anyhow::Result<PresignResponse> { | |
| 65 | + | let url = format!("{}/api/internal/upload/presign", self.base_url); | |
| 66 | + | let resp = self | |
| 67 | + | .http | |
| 68 | + | .post(&url) | |
| 69 | + | .bearer_auth(&self.service_token) | |
| 70 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 71 | + | .json(&serde_json::json!({ | |
| 72 | + | "user_id": user_id, | |
| 73 | + | "item_id": item_id, | |
| 74 | + | "file_type": file_type, | |
| 75 | + | "file_name": file_name, | |
| 76 | + | "content_type": content_type, | |
| 77 | + | })) | |
| 78 | + | .send() | |
| 79 | + | .await?; | |
| 80 | + | ||
| 81 | + | json_response(resp, "presign_upload").await | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Confirm a completed S3 upload. | |
| 85 | + | pub(crate) async fn confirm_upload( | |
| 86 | + | &self, | |
| 87 | + | user_id: &str, | |
| 88 | + | item_id: &str, | |
| 89 | + | file_type: &str, | |
| 90 | + | s3_key: &str, | |
| 91 | + | ) -> anyhow::Result<bool> { | |
| 92 | + | let url = format!("{}/api/internal/upload/confirm", self.base_url); | |
| 93 | + | let resp = self | |
| 94 | + | .http | |
| 95 | + | .post(&url) | |
| 96 | + | .bearer_auth(&self.service_token) | |
| 97 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 98 | + | .json(&serde_json::json!({ | |
| 99 | + | "user_id": user_id, | |
| 100 | + | "item_id": item_id, | |
| 101 | + | "file_type": file_type, | |
| 102 | + | "s3_key": s3_key, | |
| 103 | + | })) | |
| 104 | + | .send() | |
| 105 | + | .await?; | |
| 106 | + | ||
| 107 | + | #[derive(Deserialize)] | |
| 108 | + | struct Resp { | |
| 109 | + | success: bool, | |
| 110 | + | } | |
| 111 | + | let r: Resp = json_response(resp, "confirm_upload").await?; | |
| 112 | + | Ok(r.success) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Open a multipart upload session and get the part geometry. | |
| 116 | + | pub(crate) async fn multipart_start( | |
| 117 | + | &self, | |
| 118 | + | item_id: &str, | |
| 119 | + | file_type: &str, | |
| 120 | + | file_name: &str, | |
| 121 | + | content_type: &str, | |
| 122 | + | file_size_bytes: u64, | |
| 123 | + | ) -> anyhow::Result<MultipartStart> { | |
| 124 | + | let url = format!("{}/api/internal/upload/multipart/start", self.base_url); | |
| 125 | + | let resp = self | |
| 126 | + | .http | |
| 127 | + | .post(&url) | |
| 128 | + | .bearer_auth(&self.service_token) | |
| 129 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 130 | + | .json(&serde_json::json!({ | |
| 131 | + | "item_id": item_id, | |
| 132 | + | "file_type": file_type, | |
| 133 | + | "file_name": file_name, | |
| 134 | + | "content_type": content_type, | |
| 135 | + | "file_size_bytes": file_size_bytes, | |
| 136 | + | })) | |
| 137 | + | .send() | |
| 138 | + | .await?; | |
| 139 | + | ||
| 140 | + | json_response(resp, "multipart_start").await | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Fetch a bounded window of presigned part URLs. | |
| 144 | + | async fn multipart_parts( | |
| 145 | + | &self, | |
| 146 | + | s3_key: &str, | |
| 147 | + | upload_id: &str, | |
| 148 | + | file_size_bytes: u64, | |
| 149 | + | first_part: u32, | |
| 150 | + | count: u32, | |
| 151 | + | ) -> anyhow::Result<Vec<MultipartPartUrl>> { | |
| 152 | + | let url = format!("{}/api/internal/upload/multipart/parts", self.base_url); | |
| 153 | + | let resp = self | |
| 154 | + | .http | |
| 155 | + | .post(&url) | |
| 156 | + | .bearer_auth(&self.service_token) | |
| 157 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 158 | + | .json(&serde_json::json!({ | |
| 159 | + | "s3_key": s3_key, | |
| 160 | + | "upload_id": upload_id, | |
| 161 | + | "file_size_bytes": file_size_bytes, | |
| 162 | + | "first_part": first_part, | |
| 163 | + | "count": count, | |
| 164 | + | })) | |
| 165 | + | .send() | |
| 166 | + | .await?; | |
| 167 | + | ||
| 168 | + | let parts: MultipartPartsResponse = json_response(resp, "multipart_parts").await?; | |
| 169 | + | Ok(parts.parts) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Assemble the uploaded parts into the staging object. | |
| 173 | + | async fn multipart_complete( | |
| 174 | + | &self, | |
| 175 | + | s3_key: &str, | |
| 176 | + | upload_id: &str, | |
| 177 | + | parts: &[(i32, String)], | |
| 178 | + | ) -> anyhow::Result<()> { | |
| 179 | + | let url = format!("{}/api/internal/upload/multipart/complete", self.base_url); | |
| 180 | + | let parts: Vec<serde_json::Value> = parts | |
| 181 | + | .iter() | |
| 182 | + | .map(|(n, etag)| serde_json::json!({ "part_number": n, "etag": etag })) | |
| 183 | + | .collect(); | |
| 184 | + | let resp = self | |
| 185 | + | .http | |
| 186 | + | .post(&url) | |
| 187 | + | .bearer_auth(&self.service_token) | |
| 188 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 189 | + | .json(&serde_json::json!({ | |
| 190 | + | "s3_key": s3_key, | |
| 191 | + | "upload_id": upload_id, | |
| 192 | + | "parts": parts, | |
| 193 | + | })) | |
| 194 | + | .send() | |
| 195 | + | .await?; | |
| 196 | + | ||
| 197 | + | if !resp.status().is_success() { | |
| 198 | + | anyhow::bail!( | |
| 199 | + | "multipart_complete failed: HTTP {} {}", | |
| 200 | + | resp.status(), | |
| 201 | + | resp.text().await.unwrap_or_default() | |
| 202 | + | ); | |
| 203 | + | } | |
| 204 | + | Ok(()) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// Release the parts of an abandoned session. Incomplete multipart uploads | |
| 208 | + | /// bill for their parts until aborted. | |
| 209 | + | pub(crate) async fn multipart_abort( | |
| 210 | + | &self, | |
| 211 | + | s3_key: &str, | |
| 212 | + | upload_id: &str, | |
| 213 | + | ) -> anyhow::Result<()> { | |
| 214 | + | let url = format!("{}/api/internal/upload/multipart/abort", self.base_url); | |
| 215 | + | let resp = self | |
| 216 | + | .http | |
| 217 | + | .post(&url) | |
| 218 | + | .bearer_auth(&self.service_token) | |
| 219 | + | .header("X-MNW-Actor", self.actor_header()) | |
| 220 | + | .json(&serde_json::json!({ "s3_key": s3_key, "upload_id": upload_id })) | |
| 221 | + | .send() | |
| 222 | + | .await?; | |
| 223 | + | ||
| 224 | + | if !resp.status().is_success() { | |
| 225 | + | anyhow::bail!("multipart_abort failed: HTTP {}", resp.status()); | |
| 226 | + | } | |
| 227 | + | Ok(()) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// Upload a file through a multipart session, holding one part in memory at | |
| 231 | + | /// a time. Returns the staging key to confirm against. | |
| 232 | + | /// | |
| 233 | + | /// `on_progress` is called with `(bytes_uploaded, total)` after each part. | |
| 234 | + | /// Any failure past the session opening aborts it, so a half-finished upload | |
| 235 | + | /// does not leave parts billing indefinitely — the single abort site means a | |
| 236 | + | /// future failure path added inside cannot forget to. | |
| 237 | + | #[allow(clippy::too_many_arguments)] | |
| 238 | + | pub(crate) async fn upload_file_multipart( | |
| 239 | + | &self, | |
| 240 | + | item_id: &str, | |
| 241 | + | file_type: &str, | |
| 242 | + | file_name: &str, | |
| 243 | + | content_type: &str, | |
| 244 | + | file_path: &std::path::Path, | |
| 245 | + | file_size: u64, | |
| 246 | + | mut on_progress: impl FnMut(u64, u64), | |
| 247 | + | ) -> anyhow::Result<String> { | |
| 248 | + | let start = self | |
| 249 | + | .multipart_start(item_id, file_type, file_name, content_type, file_size) | |
| 250 | + | .await?; | |
| 251 | + | ||
| 252 | + | tracing::info!( | |
| 253 | + | s3_key = %start.s3_key, | |
| 254 | + | part_count = start.part_count, | |
| 255 | + | part_size = start.part_size, | |
| 256 | + | expires_in = start.expires_in, | |
| 257 | + | file_size, | |
| 258 | + | "multipart upload session opened" | |
| 259 | + | ); | |
| 260 | + | ||
| 261 | + | match self | |
| 262 | + | .run_multipart_upload(&start, file_path, file_size, &mut on_progress) | |
| 263 | + | .await | |
| 264 | + | { | |
| 265 | + | Ok(()) => Ok(start.s3_key), | |
| 266 | + | Err(e) => { | |
| 267 | + | if let Err(abort_err) = self.multipart_abort(&start.s3_key, &start.upload_id).await | |
| 268 | + | { | |
| 269 | + | tracing::warn!( | |
| 270 | + | error = %abort_err, s3_key = %start.s3_key, | |
| 271 | + | "failed to abort multipart upload after a failed transfer" | |
| 272 | + | ); | |
| 273 | + | } | |
| 274 | + | Err(e) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// Read and upload every part, then complete. Returns `Err` without | |
| 280 | + | /// aborting; the caller owns the single abort. | |
| 281 | + | async fn run_multipart_upload( | |
| 282 | + | &self, | |
| 283 | + | start: &MultipartStart, | |
| 284 | + | file_path: &std::path::Path, | |
| 285 | + | file_size: u64, | |
| 286 | + | on_progress: &mut impl FnMut(u64, u64), | |
| 287 | + | ) -> anyhow::Result<()> { | |
| 288 | + | use tokio::io::AsyncReadExt; | |
| 289 | + | ||
| 290 | + | let mut file = tokio::fs::File::open(file_path) | |
| 291 | + | .await | |
| 292 | + | .map_err(|e| anyhow::anyhow!("opening {} for upload: {e}", file_path.display()))?; | |
| 293 | + | ||
| 294 | + | let mut completed: Vec<(i32, String)> = Vec::with_capacity(start.part_count as usize); | |
| 295 | + | let mut uploaded: u64 = 0; | |
| 296 | + | let mut next: u32 = 1; | |
| 297 | + | ||
| 298 | + | while next <= start.part_count { | |
| 299 | + | let count = PART_URL_WINDOW.min(start.part_count - next + 1); | |
| 300 | + | let urls = self | |
| 301 | + | .multipart_parts(&start.s3_key, &start.upload_id, file_size, next, count) | |
| 302 | + | .await?; | |
| 303 | + | if urls.is_empty() { | |
| 304 | + | anyhow::bail!("server returned no part URLs for part {next}"); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | for part in urls { | |
| 308 | + | // One part resident at a time — this is the whole point of the | |
| 309 | + | // multipart path over the single-PUT one. | |
| 310 | + | let mut buf = vec![0u8; part.content_length as usize]; | |
| 311 | + | file.read_exact(&mut buf).await.map_err(|e| { | |
| 312 | + | anyhow::anyhow!( | |
| 313 | + | "reading part {} ({} bytes) from {}: {e}", | |
| 314 | + | part.part_number, | |
| 315 | + | part.content_length, | |
| 316 | + | file_path.display() | |
| 317 | + | ) | |
| 318 | + | })?; | |
| 319 | + | ||
| 320 | + | let etag = self.put_part(&part, buf).await?; | |
| 321 | + | completed.push((part.part_number, etag)); | |
| 322 | + | uploaded += part.content_length; | |
| 323 | + | on_progress(uploaded, file_size); | |
| 324 | + | next += 1; | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | self.multipart_complete(&start.s3_key, &start.upload_id, &completed) | |
| 329 | + | .await | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// PUT one part to its presigned URL, returning the ETag the completion call | |
| 333 | + | /// needs. Retries transient failures — losing a part to a network blip | |
| 334 | + | /// should not discard the whole transfer. | |
| 335 | + | async fn put_part(&self, part: &MultipartPartUrl, body: Vec<u8>) -> anyhow::Result<String> { | |
| 336 | + | // `Bytes` so a retry clones a refcount rather than re-copying the part. | |
| 337 | + | let body = bytes::Bytes::from(body); | |
| 338 | + | let mut attempt: u32 = 0; | |
| 339 | + | loop { | |
| 340 | + | attempt += 1; | |
| 341 | + | let sent = self | |
| 342 | + | .http | |
| 343 | + | .put(&part.url) | |
| 344 | + | .header(reqwest::header::CONTENT_LENGTH, part.content_length) | |
| 345 | + | .body(body.clone()) | |
| 346 | + | .send() | |
| 347 | + | .await; | |
| 348 | + | ||
| 349 | + | let retriable = match sent { | |
| 350 | + | Ok(resp) if resp.status().is_success() => { | |
| 351 | + | let etag = resp | |
| 352 | + | .headers() | |
| 353 | + | .get(reqwest::header::ETAG) | |
| 354 | + | .and_then(|v| v.to_str().ok()) | |
| 355 | + | .unwrap_or_default() | |
| 356 | + | .to_string(); | |
| 357 | + | if etag.is_empty() { | |
| 358 | + | anyhow::bail!( | |
| 359 | + | "S3 returned no ETag for part {}; cannot complete the upload", | |
| 360 | + | part.part_number | |
| 361 | + | ); | |
| 362 | + | } | |
| 363 | + | return Ok(etag); | |
| 364 | + | } | |
| 365 | + | Ok(resp) => { | |
| 366 | + | let status = resp.status(); | |
| 367 | + | if attempt >= 3 { | |
| 368 | + | anyhow::bail!( | |
| 369 | + | "part {} failed after {attempt} attempts: HTTP {status}", | |
| 370 | + | part.part_number | |
| 371 | + | ); | |
| 372 | + | } | |
| 373 | + | format!("HTTP {status}") | |
| 374 | + | } | |
| 375 | + | Err(e) => { | |
| 376 | + | if attempt >= 3 { | |
| 377 | + | return Err(anyhow::Error::new(e).context(format!( | |
| 378 | + | "part {} failed after {attempt} attempts", | |
| 379 | + | part.part_number | |
| 380 | + | ))); | |
| 381 | + | } | |
| 382 | + | e.to_string() | |
| 383 | + | } | |
| 384 | + | }; | |
| 385 | + | ||
| 386 | + | let delay_ms = 200u64 * (1u64 << ((attempt - 1) * 2)); | |
| 387 | + | tracing::warn!( | |
| 388 | + | part_number = part.part_number, attempt, delay_ms, error = %retriable, | |
| 389 | + | "part upload transient failure, retrying" | |
| 390 | + | ); | |
| 391 | + | tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /// Upload a file to S3 using a presigned URL. | |
| 396 | + | pub(crate) async fn upload_to_s3( | |
| 397 | + | &self, | |
| 398 | + | presigned_url: &str, | |
| 399 | + | file_path: &std::path::Path, | |
| 400 | + | content_type: &str, | |
| 401 | + | cache_control: Option<&str>, | |
| 402 | + | ) -> anyhow::Result<()> { | |
| 403 | + | let data = tokio::fs::read(file_path).await?; | |
| 404 | + | let mut req = self | |
| 405 | + | .http | |
| 406 | + | .put(presigned_url) | |
| 407 | + | .header("content-type", content_type) | |
| 408 | + | .body(data); | |
| 409 | + | ||
| 410 | + | if let Some(cc) = cache_control { | |
| 411 | + | req = req.header("cache-control", cc); | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | let resp = req.send().await?; | |
| 415 | + | ||
| 416 | + | if !resp.status().is_success() { | |
| 417 | + | anyhow::bail!("S3 upload failed: HTTP {}", resp.status()); | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | Ok(()) | |
| 421 | + | } | |
| 422 | + | } |