From 65677504f6ba0e0c669fca29b4c36f4f1efb7665 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 16:58:19 +0100 Subject: [PATCH] Per-user campaign storage and public campaign list Co-Authored-By: Claude Opus 5.5 --- nginx.conf | 2 +- server/Cargo.lock | 1 + server/Cargo.toml | 3 + server/src/main.rs | 363 +++++++++++++++++++++++++++++++++++++++++++-- src/cloud.rs | 170 ++++++++++++++++++++- 5 files changed, 527 insertions(+), 12 deletions(-) diff --git a/nginx.conf b/nginx.conf index f7c190a..bf4fb80 100644 --- a/nginx.conf +++ b/nginx.conf @@ -34,7 +34,7 @@ server { resolver 127.0.0.11 valid=10s ipv6=off; set $saves http://saves:8080; proxy_pass $saves; - client_max_body_size 2m; + client_max_body_size 33m; # campaigns up to 32 MB (server/src/main.rs MAX_CAMPAIGN) proxy_read_timeout 15s; } } diff --git a/server/Cargo.lock b/server/Cargo.lock index 7f41b2d..7b79755 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -164,6 +164,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tower", ] [[package]] diff --git a/server/Cargo.toml b/server/Cargo.toml index 6190239..07b6b07 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -14,6 +14,9 @@ argon2 = "0.5" getrandom = "0.2" futures-util = "0.3.34" +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } + [profile.release] opt-level = 3 lto = true diff --git a/server/src/main.rs b/server/src/main.rs index f7d1595..bb9c614 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -18,8 +18,17 @@ // POST /api/saves?name=.. -> store a new savegame (body = savegame JSON) // PUT /api/saves/{id}?name= -> overwrite a savegame // DELETE /api/saves/{id} +// GET /api/campaigns -> own campaigns [{id, name, updated, published, size}] (newest first) +// GET /api/campaigns/{id} -> campaign JSON (own) +// POST /api/campaigns?name=.. -> store a new campaign (body = campaign JSON) -> {id, ..} +// PUT /api/campaigns/{id}?name=.. -> overwrite an own campaign (keeps the published flag) +// DELETE /api/campaigns/{id} +// POST /api/campaigns/{id}/publish -> {published: true|false} +// GET /api/public/campaigns -> published campaigns of all users [{owner, id, name, updated, size}] +// GET /api/public/campaigns/{owner}/{id} -> JSON of a published campaign (404 when not published) // -// Limits: 1 MB per savegame, 500 MB in total, 30 write requests and 20 login attempts per minute. +// Limits: 1 MB per savegame, 32 MB per campaign, 500 MB in total, 30 write requests and +// 20 login attempts per minute. use argon2::Argon2; use axum::{ @@ -43,6 +52,7 @@ mod netplay; use netplay::Netplay; const MAX_SAVE: usize = 1024 * 1024; +const MAX_CAMPAIGN: usize = 32 * 1024 * 1024; const MAX_TOTAL: u64 = 500 * 1024 * 1024; const WRITES_PER_MINUTE: usize = 30; const LOGINS_PER_MINUTE: usize = 20; @@ -77,6 +87,32 @@ struct SaveInfo { time: f64, } +// stored next to a campaign as .meta +#[derive(Serialize, Deserialize, Clone)] +struct CampaignInfo { + id: String, + name: String, + updated: u64, + #[serde(default)] + published: bool, + #[serde(default)] + size: usize, +} + +#[derive(Serialize)] +struct PublicCampaignInfo { + owner: String, + id: String, + name: String, + updated: u64, + size: usize, +} + +#[derive(Deserialize)] +struct PublishRequest { + published: bool, +} + #[derive(Deserialize)] struct NameQuery { name: Option, @@ -132,9 +168,13 @@ fn valid_user(name: &str) -> bool { } fn clean_name(n: Option) -> String { + clean_name_or(n, "Savegame") +} + +fn clean_name_or(n: Option, fallback: &str) -> String { let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect(); if n.trim().is_empty() { - "Savegame".into() + fallback.into() } else { n.trim().to_string() } @@ -160,6 +200,10 @@ impl AppState { self.dir.join("users").join(name) } + fn campaign_dir(&self, name: &str) -> PathBuf { + self.user_dir(name).join("campaigns") + } + async fn store_users(&self) { let users = self.users.lock().await; let _ = tokio::fs::write(self.dir.join("users.json"), serde_json::to_vec_pretty(&*users).unwrap_or_default()).await; @@ -366,6 +410,7 @@ async fn delete_user(State(st): State, headers: HeaderMap, Path(name): st.sessions.lock().await.retain(|_, n| *n != name); st.store_users().await; st.store_sessions().await; + // savegames and campaigns (users//campaigns) of the user let _ = tokio::fs::remove_dir_all(st.user_dir(&name)).await; StatusCode::NO_CONTENT.into_response() } @@ -449,8 +494,7 @@ async fn create(State(st): State, headers: HeaderMap, Query(q): Query< Ok(u) => u, Err(r) => return r, }; - let id = format!("{}-{:04x}", now(), (now_nanos() % 0xffff) as u16); - store(&st, &u, id, q.name, body).await + store(&st, &u, new_id(), q.name, body).await } async fn overwrite(State(st): State, headers: HeaderMap, Path(id): Path, Query(q): Query, body: Bytes) -> Response { @@ -481,6 +525,177 @@ async fn delete_save(State(st): State, headers: HeaderMap, Path(id): P StatusCode::NO_CONTENT.into_response() } +// ---------------- campaigns (users//campaigns/.json + .meta) + +fn new_id() -> String { + format!("{}-{:04x}", now(), (now_nanos() % 0xffff) as u16) +} + +async fn read_campaign_meta(dir: &std::path::Path, id: &str) -> Option { + let b = tokio::fs::read(dir.join(format!("{}.meta", id))).await.ok()?; + serde_json::from_slice(&b).ok() +} + +async fn campaign_infos(dir: &std::path::Path) -> Vec { + let mut out: Vec = Vec::new(); + if let Ok(mut rd) = tokio::fs::read_dir(dir).await { + while let Ok(Some(e)) = rd.next_entry().await { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) == Some("meta") { + if let Ok(b) = tokio::fs::read(&p).await { + if let Ok(info) = serde_json::from_slice::(&b) { + out.push(info); + } + } + } + } + } + out.sort_by(|a, b| b.updated.cmp(&a.updated)); + out +} + +async fn list_campaigns(State(st): State, headers: HeaderMap) -> Response { + let u = match st.auth(&headers).await { + Ok(u) => u, + Err(r) => return r, + }; + Json(campaign_infos(&st.campaign_dir(&u.name)).await).into_response() +} + +async fn get_campaign(State(st): State, headers: HeaderMap, Path(id): Path) -> Response { + let u = match st.auth(&headers).await { + Ok(u) => u, + Err(r) => return r, + }; + if !valid_id(&id) { + return err(StatusCode::BAD_REQUEST, "bad id"); + } + match tokio::fs::read(st.campaign_dir(&u.name).join(format!("{}.json", id))).await { + Ok(b) => ([("content-type", "application/json")], b).into_response(), + Err(_) => err(StatusCode::NOT_FOUND, "not found"), + } +} + +// overwriting keeps the published flag of the existing campaign (new ones start unpublished) +async fn store_campaign(st: &AppState, user: &User, id: String, name: Option, body: Bytes) -> Response { + if body.len() > MAX_CAMPAIGN { + return err(StatusCode::PAYLOAD_TOO_LARGE, "campaign too large"); + } + if limited(&st.writes, WRITES_PER_MINUTE).await { + return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + if total_size(&st.dir).await + body.len() as u64 > MAX_TOTAL { + return err(StatusCode::INSUFFICIENT_STORAGE, "storage full"); + } + // the campaign document itself is opaque; it only has to be a JSON object + match serde_json::from_slice::(&body) { + Ok(v) if v.is_object() => {} + _ => return err(StatusCode::BAD_REQUEST, "not JSON"), + } + let dir = st.campaign_dir(&user.name); + let published = read_campaign_meta(&dir, &id).await.map(|m| m.published).unwrap_or(false); + let info = CampaignInfo { id: id.clone(), name: clean_name_or(name, "Campaign"), updated: now(), published, size: body.len() }; + let _ = tokio::fs::create_dir_all(&dir).await; + let ok = tokio::fs::write(dir.join(format!("{}.json", id)), &body).await.is_ok() + && tokio::fs::write(dir.join(format!("{}.meta", id)), serde_json::to_vec(&info).unwrap_or_default()).await.is_ok(); + if !ok { + return err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"); + } + Json(info).into_response() +} + +async fn create_campaign(State(st): State, headers: HeaderMap, Query(q): Query, body: Bytes) -> Response { + let u = match st.auth(&headers).await { + Ok(u) => u, + Err(r) => return r, + }; + store_campaign(&st, &u, new_id(), q.name, body).await +} + +async fn overwrite_campaign(State(st): State, headers: HeaderMap, Path(id): Path, Query(q): Query, body: Bytes) -> Response { + let u = match st.auth(&headers).await { + Ok(u) => u, + Err(r) => return r, + }; + if !valid_id(&id) { + return err(StatusCode::BAD_REQUEST, "bad id"); + } + store_campaign(&st, &u, id, q.name, body).await +} + +async fn delete_campaign(State(st): State, headers: HeaderMap, Path(id): Path) -> Response { + let u = match st.auth(&headers).await { + Ok(u) => u, + Err(r) => return r, + }; + if !valid_id(&id) { + return err(StatusCode::BAD_REQUEST, "bad id"); + } + if limited(&st.writes, WRITES_PER_MINUTE).await { + return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + let dir = st.campaign_dir(&u.name); + let _ = tokio::fs::remove_file(dir.join(format!("{}.json", id))).await; + let _ = tokio::fs::remove_file(dir.join(format!("{}.meta", id))).await; + StatusCode::NO_CONTENT.into_response() +} + +async fn publish_campaign(State(st): State, headers: HeaderMap, Path(id): Path, Json(p): Json) -> Response { + let u = match st.auth(&headers).await { + Ok(u) => u, + Err(r) => return r, + }; + if !valid_id(&id) { + return err(StatusCode::BAD_REQUEST, "bad id"); + } + if limited(&st.writes, WRITES_PER_MINUTE).await { + return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + let dir = st.campaign_dir(&u.name); + let Some(mut info) = read_campaign_meta(&dir, &id).await else { + return err(StatusCode::NOT_FOUND, "not found"); + }; + info.published = p.published; + if tokio::fs::write(dir.join(format!("{}.meta", id)), serde_json::to_vec(&info).unwrap_or_default()).await.is_err() { + return err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"); + } + Json(info).into_response() +} + +async fn list_public_campaigns(State(st): State, headers: HeaderMap) -> Response { + if let Err(r) = st.auth(&headers).await { + return r; + } + let owners: Vec = st.users.lock().await.iter().map(|u| u.name.clone()).collect(); + let mut out: Vec = Vec::new(); + for owner in owners { + for c in campaign_infos(&st.campaign_dir(&owner)).await { + if c.published { + out.push(PublicCampaignInfo { owner: owner.clone(), id: c.id, name: c.name, updated: c.updated, size: c.size }); + } + } + } + out.sort_by(|a, b| b.updated.cmp(&a.updated)); + Json(out).into_response() +} + +async fn get_public_campaign(State(st): State, headers: HeaderMap, Path((owner, id)): Path<(String, String)>) -> Response { + if let Err(r) = st.auth(&headers).await { + return r; + } + if !valid_user(&owner) || !valid_id(&id) { + return err(StatusCode::BAD_REQUEST, "bad id"); + } + let dir = st.campaign_dir(&owner); + if !read_campaign_meta(&dir, &id).await.is_some_and(|m| m.published) { + return err(StatusCode::NOT_FOUND, "not found"); + } + match tokio::fs::read(dir.join(format!("{}.json", id))).await { + Ok(b) => ([("content-type", "application/json")], b).into_response(), + Err(_) => err(StatusCode::NOT_FOUND, "not found"), + } +} + // Maintenance: "dungeoncraft-saves set-password " sets a user's password // (stop the service first or restart it afterwards; the user's sessions end). fn set_password_cli(dir: &std::path::Path, name: &str, password: &str) { @@ -519,7 +734,17 @@ async fn main() { logins: Arc::new(Mutex::new(Vec::new())), netplay: Arc::new(Mutex::new(Netplay::new())), }; - let app = Router::new() + let app = router(state); + let addr = std::env::var("LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into()); + let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind"); + println!("DungeonCraft service on {}", addr); + axum::serve(listener, app).await.expect("server"); +} + +fn router(state: AppState) -> Router { + // campaigns embed map snapshots: their write routes get a larger body limit + let campaign_limit = || DefaultBodyLimit::max(MAX_CAMPAIGN + 1024); + Router::new() .route("/api/health", get(health)) .route("/api/login", post(login)) .route("/api/logout", post(logout)) @@ -531,10 +756,128 @@ async fn main() { .route("/api/netplay/ws", get(netplay::netplay_ws)) .route("/api/saves", get(list).post(create)) .route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save)) + .route("/api/campaigns", get(list_campaigns).post(create_campaign).layer(campaign_limit())) + .route("/api/campaigns/{id}", get(get_campaign).put(overwrite_campaign).delete(delete_campaign).layer(campaign_limit())) + .route("/api/campaigns/{id}/publish", post(publish_campaign)) + .route("/api/public/campaigns", get(list_public_campaigns)) + .route("/api/public/campaigns/{owner}/{id}", get(get_public_campaign)) .layer(DefaultBodyLimit::max(MAX_SAVE + 1024)) - .with_state(state); - let addr = std::env::var("LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into()); - let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind"); - println!("DungeonCraft service on {}", addr); - axum::serve(listener, app).await.expect("server"); + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + fn test_state(tag: &str) -> AppState { + let dir = std::env::temp_dir().join(format!("dc-saves-test-{}-{}", tag, random_hex(6))); + std::fs::create_dir_all(dir.join("users")).unwrap(); + let mk = |name: &str, admin: bool| User { name: name.into(), salt: String::new(), hash: String::new(), admin }; + let users = vec![mk("boss", true), mk("alice", false), mk("bob", false)]; + let sessions: HashMap = + [("tboss", "boss"), ("talice", "alice"), ("tbob", "bob")].iter().map(|(t, n)| (t.to_string(), n.to_string())).collect(); + AppState { + dir, + users: Arc::new(Mutex::new(users)), + sessions: Arc::new(Mutex::new(sessions)), + writes: Arc::new(Mutex::new(Vec::new())), + logins: Arc::new(Mutex::new(Vec::new())), + netplay: Arc::new(Mutex::new(Netplay::new())), + } + } + + async fn call(st: &AppState, method: &str, uri: &str, token: &str, body: Vec) -> (StatusCode, Vec) { + let req = Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {}", token)) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(); + let res = router(st.clone()).oneshot(req).await.unwrap(); + let status = res.status(); + let b = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); + (status, b.to_vec()) + } + + fn json(b: &[u8]) -> serde_json::Value { + serde_json::from_slice(b).unwrap() + } + + #[tokio::test] + async fn campaigns_store_publish_and_share() { + let st = test_state("camp"); + // a few MB: more than the savegame limit + let big = format!("{{\"format\":\"dungeoncraft-campaign\",\"pad\":\"{}\"}}", "x".repeat(3 * 1024 * 1024)); + let (s, b) = call(&st, "POST", "/api/campaigns?name=My%20Camp", "talice", big.clone().into_bytes()).await; + assert_eq!(s, StatusCode::OK); + let id = json(&b)["id"].as_str().unwrap().to_string(); + + // not JSON / no login + assert_eq!(call(&st, "POST", "/api/campaigns", "talice", b"nope".to_vec()).await.0, StatusCode::BAD_REQUEST); + assert_eq!(call(&st, "GET", "/api/campaigns", "wrong", vec![]).await.0, StatusCode::UNAUTHORIZED); + + let (s, b) = call(&st, "GET", "/api/campaigns", "talice", vec![]).await; + assert_eq!(s, StatusCode::OK); + let l = json(&b); + assert_eq!(l[0]["name"], "My Camp"); + assert_eq!(l[0]["published"], false); + let (s, b) = call(&st, "GET", &format!("/api/campaigns/{}", id), "talice", vec![]).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b, big.as_bytes()); + // other users do not see it, not even by id + assert_eq!(json(&call(&st, "GET", "/api/campaigns", "tbob", vec![]).await.1).as_array().unwrap().len(), 0); + assert_eq!(call(&st, "GET", &format!("/api/campaigns/{}", id), "tbob", vec![]).await.0, StatusCode::NOT_FOUND); + let public = format!("/api/public/campaigns/alice/{}", id); + assert_eq!(call(&st, "GET", &public, "tbob", vec![]).await.0, StatusCode::NOT_FOUND); + + // publish: listed and readable for everybody logged in + let (s, _) = call(&st, "POST", &format!("/api/campaigns/{}/publish", id), "talice", br#"{"published":true}"#.to_vec()).await; + assert_eq!(s, StatusCode::OK); + let l = json(&call(&st, "GET", "/api/public/campaigns", "tbob", vec![]).await.1); + assert_eq!(l.as_array().unwrap().len(), 1); + assert_eq!(l[0]["owner"], "alice"); + assert_eq!(l[0]["id"], id.as_str()); + let (s, b) = call(&st, "GET", &public, "tbob", vec![]).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(b.len(), big.len()); + assert_eq!(call(&st, "GET", "/api/public/campaigns", "", vec![]).await.0, StatusCode::UNAUTHORIZED); + + // overwrite keeps the published flag and renames + let (s, b) = call(&st, "PUT", &format!("/api/campaigns/{}?name=Renamed", id), "talice", b"{\"v\":2}".to_vec()).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(json(&b)["published"], true); + assert_eq!(json(&b)["name"], "Renamed"); + + // unpublish + call(&st, "POST", &format!("/api/campaigns/{}/publish", id), "talice", br#"{"published":false}"#.to_vec()).await; + assert_eq!(call(&st, "GET", &public, "tbob", vec![]).await.0, StatusCode::NOT_FOUND); + assert_eq!(json(&call(&st, "GET", "/api/public/campaigns", "tbob", vec![]).await.1).as_array().unwrap().len(), 0); + + // savegames keep their small limit + let (s, _) = call(&st, "POST", "/api/saves", "talice", big.into_bytes()).await; + assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE); + + // delete + assert_eq!(call(&st, "DELETE", &format!("/api/campaigns/{}", id), "talice", vec![]).await.0, StatusCode::NO_CONTENT); + assert_eq!(call(&st, "GET", &format!("/api/campaigns/{}", id), "talice", vec![]).await.0, StatusCode::NOT_FOUND); + let _ = std::fs::remove_dir_all(&st.dir); + } + + #[tokio::test] + async fn deleting_a_user_deletes_the_campaigns() { + let st = test_state("deluser"); + let (s, b) = call(&st, "POST", "/api/campaigns?name=C", "tbob", b"{}".to_vec()).await; + assert_eq!(s, StatusCode::OK); + let id = json(&b)["id"].as_str().unwrap().to_string(); + call(&st, "POST", &format!("/api/campaigns/{}/publish", id), "tbob", br#"{"published":true}"#.to_vec()).await; + assert!(st.campaign_dir("bob").join(format!("{}.json", id)).exists()); + assert_eq!(call(&st, "DELETE", "/api/users/bob", "tboss", vec![]).await.0, StatusCode::NO_CONTENT); + assert!(!st.campaign_dir("bob").exists()); + assert_eq!(json(&call(&st, "GET", "/api/public/campaigns", "talice", vec![]).await.1).as_array().unwrap().len(), 0); + let _ = std::fs::remove_dir_all(&st.dir); + } } diff --git a/src/cloud.rs b/src/cloud.rs index dc3fed5..f26f92d 100644 --- a/src/cloud.rs +++ b/src/cloud.rs @@ -1,5 +1,8 @@ -// Client for the savegame service (server/): a shared list of savegames on the web server. +// Client for the savegame service (server/): a shared list of savegames on the web server, +// plus the own campaigns and the campaigns other users published. // All requests are asynchronous; poll() is called once per frame and reports finished requests. +// Campaign requests report through fields/take_* functions instead of new events: on success +// poll() returns CloudEvent::None, on failure CloudEvent::Error (also kept in campaign_error). // When the service cannot be reached (no server, or the game was opened as a local file), // `online` becomes false and the game falls back to savegame files and the browser autosave. @@ -23,6 +26,31 @@ pub struct SaveInfo { pub time: f64, } +// an own campaign on the server +#[allow(dead_code)] +#[derive(Clone, Deserialize, Debug)] +pub struct CampaignInfo { + pub id: String, + pub name: String, + pub updated: u64, + #[serde(default)] + pub published: bool, + #[serde(default)] + pub size: u64, +} + +// a campaign some user published +#[allow(dead_code)] +#[derive(Clone, Deserialize, Debug)] +pub struct PublicCampaignInfo { + pub owner: String, + pub id: String, + pub name: String, + pub updated: u64, + #[serde(default)] + pub size: u64, +} + #[derive(Clone, Copy, PartialEq, Debug)] pub enum Online { Unknown, @@ -30,6 +58,7 @@ pub enum Online { No, } +#[allow(dead_code)] #[derive(Clone, Copy, PartialEq, Debug)] enum Req { Health, @@ -44,6 +73,13 @@ enum Req { Users, CreateUser, DeleteUser, + CampaignList, + CampaignLoad, + CampaignSave, + CampaignDelete, + CampaignPublish, + PublicList, + PublicLoad, } // the logged-in account @@ -82,6 +118,13 @@ pub struct Cloud { pub list: Vec, pub listed: bool, // the list has been fetched at least once pub users: Vec, + pub campaigns: Vec, // own campaigns (newest first) + pub campaigns_listed: bool, + pub public_campaigns: Vec, // published campaigns of all users + pub public_listed: bool, + pub campaign_error: Option<&'static str>, // language key of the last failed campaign request + loaded_campaign: Option, + saved_campaign: Option, pending: Option<(u32, Req)>, save_name: String, login_name: String, @@ -127,6 +170,13 @@ impl Cloud { list: Vec::new(), listed: false, users: Vec::new(), + campaigns: Vec::new(), + campaigns_listed: false, + public_campaigns: Vec::new(), + public_listed: false, + campaign_error: None, + loaded_campaign: None, + saved_campaign: None, pending: Some((id, Req::Health)), save_name: String::new(), login_name: String::new(), @@ -326,10 +376,120 @@ impl Cloud { self.refresh(); CloudEvent::Deleted } + Req::CampaignList => { + if !ok { + return self.campaign_failed(res.status); + } + match serde_json::from_slice::>(&res.body) { + Ok(l) => { + self.campaigns = l; + self.campaigns_listed = true; + CloudEvent::None + } + Err(_) => self.campaign_failed(500), + } + } + Req::PublicList => { + if !ok { + return self.campaign_failed(res.status); + } + match serde_json::from_slice::>(&res.body) { + Ok(l) => { + self.public_campaigns = l; + self.public_listed = true; + CloudEvent::None + } + Err(_) => self.campaign_failed(500), + } + } + Req::CampaignLoad | Req::PublicLoad => { + if !ok { + return self.campaign_failed(res.status); + } + self.loaded_campaign = Some(String::from_utf8_lossy(&res.body).into_owned()); + CloudEvent::None + } + Req::CampaignSave => { + if !ok { + return self.campaign_failed(res.status); + } + self.saved_campaign = serde_json::from_slice::(&res.body).ok(); + self.refresh_campaigns(); + CloudEvent::None + } + Req::CampaignDelete | Req::CampaignPublish => { + if !ok { + return self.campaign_failed(res.status); + } + self.refresh_campaigns(); + CloudEvent::None + } } } } +// campaigns on the server (the campaign editor UI drives these) +#[allow(dead_code)] +impl Cloud { + // ---------------- campaigns. Every call returns false while another request is running. + + pub fn refresh_campaigns(&mut self) -> bool { + self.start("GET", "campaigns", &[], Req::CampaignList) + } + + // the campaign JSON arrives later through take_loaded_campaign() + pub fn load_campaign(&mut self, id: &str) -> bool { + self.start("GET", &format!("campaigns/{}", encode(id)), &[], Req::CampaignLoad) + } + + // id = None stores a new campaign, Some(id) overwrites an own one; + // the stored entry (with its id) arrives through take_saved_campaign() + pub fn save_campaign(&mut self, id: Option<&str>, name: &str, json: &str) -> bool { + match id { + None => self.start("POST", &format!("campaigns?name={}", encode(name)), json.as_bytes(), Req::CampaignSave), + Some(id) => self.start("PUT", &format!("campaigns/{}?name={}", encode(id), encode(name)), json.as_bytes(), Req::CampaignSave), + } + } + + pub fn delete_campaign(&mut self, id: &str) -> bool { + self.start("DELETE", &format!("campaigns/{}", encode(id)), &[], Req::CampaignDelete) + } + + pub fn publish_campaign(&mut self, id: &str, published: bool) -> bool { + let body = serde_json::json!({ "published": published }).to_string(); + self.start("POST", &format!("campaigns/{}/publish", encode(id)), body.as_bytes(), Req::CampaignPublish) + } + + pub fn refresh_public_campaigns(&mut self) -> bool { + self.start("GET", "public/campaigns", &[], Req::PublicList) + } + + // the campaign JSON arrives later through take_loaded_campaign() + pub fn load_public_campaign(&mut self, owner: &str, id: &str) -> bool { + self.start("GET", &format!("public/campaigns/{}/{}", encode(owner), encode(id)), &[], Req::PublicLoad) + } + + // JSON text of a campaign loaded by load_campaign() or load_public_campaign() + pub fn take_loaded_campaign(&mut self) -> Option { + self.loaded_campaign.take() + } + + // the entry of the campaign stored by save_campaign() + pub fn take_saved_campaign(&mut self) -> Option { + self.saved_campaign.take() + } + + pub fn take_campaign_error(&mut self) -> Option<&'static str> { + self.campaign_error.take() + } + + fn campaign_failed(&mut self, status: i32) -> CloudEvent { + let key = error_key(status); + self.campaign_error = Some(key); + CloudEvent::Error(key) + } +} + // (year, month, day, hour, minute) in UTC from unix seconds (Howard Hinnant's civil date algorithm) pub fn civil(secs: i64) -> (i64, i64, i64, i64, i64) { let days = secs.div_euclid(86400); @@ -374,4 +534,12 @@ mod tests { assert_eq!(duration_string(3723.0), "1:02:03"); assert_eq!(encode("Wave 3 – ä"), "Wave%203%20%E2%80%93%20%C3%A4"); } + + #[test] + fn campaign_lists_parse() { + let own: Vec = serde_json::from_str(r#"[{"id":"17-ab","name":"C","updated":5,"published":true,"size":9}]"#).unwrap(); + assert_eq!((own[0].id.as_str(), own[0].published, own[0].size), ("17-ab", true, 9)); + let public: Vec = serde_json::from_str(r#"[{"owner":"alice","id":"1-0","name":"P","updated":7}]"#).unwrap(); + assert_eq!((public[0].owner.as_str(), public[0].updated), ("alice", 7)); + } }