Per-user campaign storage and public campaign list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
535800c7dc
commit
65677504f6
5 files changed
+527
-12
No files matched your search
+1
-1
@@ -34,7 +34,7 @@ server {
|
|||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $saves http://saves:8080;
|
set $saves http://saves:8080;
|
||||||
proxy_pass $saves;
|
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;
|
proxy_read_timeout 15s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+1
@@ -164,6 +164,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ argon2 = "0.5"
|
|||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
futures-util = "0.3.34"
|
futures-util = "0.3.34"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
+353
-10
@@ -18,8 +18,17 @@
|
|||||||
// POST /api/saves?name=.. -> store a new savegame (body = savegame JSON)
|
// POST /api/saves?name=.. -> store a new savegame (body = savegame JSON)
|
||||||
// PUT /api/saves/{id}?name= -> overwrite a savegame
|
// PUT /api/saves/{id}?name= -> overwrite a savegame
|
||||||
// DELETE /api/saves/{id}
|
// 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 argon2::Argon2;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -43,6 +52,7 @@ mod netplay;
|
|||||||
use netplay::Netplay;
|
use netplay::Netplay;
|
||||||
|
|
||||||
const MAX_SAVE: usize = 1024 * 1024;
|
const MAX_SAVE: usize = 1024 * 1024;
|
||||||
|
const MAX_CAMPAIGN: usize = 32 * 1024 * 1024;
|
||||||
const MAX_TOTAL: u64 = 500 * 1024 * 1024;
|
const MAX_TOTAL: u64 = 500 * 1024 * 1024;
|
||||||
const WRITES_PER_MINUTE: usize = 30;
|
const WRITES_PER_MINUTE: usize = 30;
|
||||||
const LOGINS_PER_MINUTE: usize = 20;
|
const LOGINS_PER_MINUTE: usize = 20;
|
||||||
@@ -77,6 +87,32 @@ struct SaveInfo {
|
|||||||
time: f64,
|
time: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stored next to a campaign as <id>.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)]
|
#[derive(Deserialize)]
|
||||||
struct NameQuery {
|
struct NameQuery {
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
@@ -132,9 +168,13 @@ fn valid_user(name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn clean_name(n: Option<String>) -> String {
|
fn clean_name(n: Option<String>) -> String {
|
||||||
|
clean_name_or(n, "Savegame")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_name_or(n: Option<String>, fallback: &str) -> String {
|
||||||
let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect();
|
let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect();
|
||||||
if n.trim().is_empty() {
|
if n.trim().is_empty() {
|
||||||
"Savegame".into()
|
fallback.into()
|
||||||
} else {
|
} else {
|
||||||
n.trim().to_string()
|
n.trim().to_string()
|
||||||
}
|
}
|
||||||
@@ -160,6 +200,10 @@ impl AppState {
|
|||||||
self.dir.join("users").join(name)
|
self.dir.join("users").join(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn campaign_dir(&self, name: &str) -> PathBuf {
|
||||||
|
self.user_dir(name).join("campaigns")
|
||||||
|
}
|
||||||
|
|
||||||
async fn store_users(&self) {
|
async fn store_users(&self) {
|
||||||
let users = self.users.lock().await;
|
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;
|
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<AppState>, headers: HeaderMap, Path(name):
|
|||||||
st.sessions.lock().await.retain(|_, n| *n != name);
|
st.sessions.lock().await.retain(|_, n| *n != name);
|
||||||
st.store_users().await;
|
st.store_users().await;
|
||||||
st.store_sessions().await;
|
st.store_sessions().await;
|
||||||
|
// savegames and campaigns (users/<name>/campaigns) of the user
|
||||||
let _ = tokio::fs::remove_dir_all(st.user_dir(&name)).await;
|
let _ = tokio::fs::remove_dir_all(st.user_dir(&name)).await;
|
||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
@@ -449,8 +494,7 @@ async fn create(State(st): State<AppState>, headers: HeaderMap, Query(q): Query<
|
|||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(r) => return r,
|
Err(r) => return r,
|
||||||
};
|
};
|
||||||
let id = format!("{}-{:04x}", now(), (now_nanos() % 0xffff) as u16);
|
store(&st, &u, new_id(), q.name, body).await
|
||||||
store(&st, &u, id, q.name, body).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn overwrite(State(st): State<AppState>, headers: HeaderMap, Path(id): Path<String>, Query(q): Query<NameQuery>, body: Bytes) -> Response {
|
async fn overwrite(State(st): State<AppState>, headers: HeaderMap, Path(id): Path<String>, Query(q): Query<NameQuery>, body: Bytes) -> Response {
|
||||||
@@ -481,6 +525,177 @@ async fn delete_save(State(st): State<AppState>, headers: HeaderMap, Path(id): P
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- campaigns (users/<name>/campaigns/<id>.json + <id>.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<CampaignInfo> {
|
||||||
|
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<CampaignInfo> {
|
||||||
|
let mut out: Vec<CampaignInfo> = 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::<CampaignInfo>(&b) {
|
||||||
|
out.push(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| b.updated.cmp(&a.updated));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_campaigns(State(st): State<AppState>, 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<AppState>, headers: HeaderMap, Path(id): Path<String>) -> 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<String>, 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::<serde_json::Value>(&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<AppState>, headers: HeaderMap, Query(q): Query<NameQuery>, 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<AppState>, headers: HeaderMap, Path(id): Path<String>, Query(q): Query<NameQuery>, 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<AppState>, headers: HeaderMap, Path(id): Path<String>) -> 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<AppState>, headers: HeaderMap, Path(id): Path<String>, Json(p): Json<PublishRequest>) -> 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<AppState>, headers: HeaderMap) -> Response {
|
||||||
|
if let Err(r) = st.auth(&headers).await {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let owners: Vec<String> = st.users.lock().await.iter().map(|u| u.name.clone()).collect();
|
||||||
|
let mut out: Vec<PublicCampaignInfo> = 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<AppState>, 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 <name> <password>" sets a user's password
|
// Maintenance: "dungeoncraft-saves set-password <name> <password>" sets a user's password
|
||||||
// (stop the service first or restart it afterwards; the user's sessions end).
|
// (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) {
|
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())),
|
logins: Arc::new(Mutex::new(Vec::new())),
|
||||||
netplay: Arc::new(Mutex::new(Netplay::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/health", get(health))
|
||||||
.route("/api/login", post(login))
|
.route("/api/login", post(login))
|
||||||
.route("/api/logout", post(logout))
|
.route("/api/logout", post(logout))
|
||||||
@@ -531,10 +756,128 @@ async fn main() {
|
|||||||
.route("/api/netplay/ws", get(netplay::netplay_ws))
|
.route("/api/netplay/ws", get(netplay::netplay_ws))
|
||||||
.route("/api/saves", get(list).post(create))
|
.route("/api/saves", get(list).post(create))
|
||||||
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save))
|
.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))
|
.layer(DefaultBodyLimit::max(MAX_SAVE + 1024))
|
||||||
.with_state(state);
|
.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);
|
#[cfg(test)]
|
||||||
axum::serve(listener, app).await.expect("server");
|
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<String, String> =
|
||||||
|
[("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<u8>) -> (StatusCode, Vec<u8>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+169
-1
@@ -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.
|
// 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),
|
// 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.
|
// `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,
|
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)]
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||||
pub enum Online {
|
pub enum Online {
|
||||||
Unknown,
|
Unknown,
|
||||||
@@ -30,6 +58,7 @@ pub enum Online {
|
|||||||
No,
|
No,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||||
enum Req {
|
enum Req {
|
||||||
Health,
|
Health,
|
||||||
@@ -44,6 +73,13 @@ enum Req {
|
|||||||
Users,
|
Users,
|
||||||
CreateUser,
|
CreateUser,
|
||||||
DeleteUser,
|
DeleteUser,
|
||||||
|
CampaignList,
|
||||||
|
CampaignLoad,
|
||||||
|
CampaignSave,
|
||||||
|
CampaignDelete,
|
||||||
|
CampaignPublish,
|
||||||
|
PublicList,
|
||||||
|
PublicLoad,
|
||||||
}
|
}
|
||||||
|
|
||||||
// the logged-in account
|
// the logged-in account
|
||||||
@@ -82,6 +118,13 @@ pub struct Cloud {
|
|||||||
pub list: Vec<SaveInfo>,
|
pub list: Vec<SaveInfo>,
|
||||||
pub listed: bool, // the list has been fetched at least once
|
pub listed: bool, // the list has been fetched at least once
|
||||||
pub users: Vec<UserInfo>,
|
pub users: Vec<UserInfo>,
|
||||||
|
pub campaigns: Vec<CampaignInfo>, // own campaigns (newest first)
|
||||||
|
pub campaigns_listed: bool,
|
||||||
|
pub public_campaigns: Vec<PublicCampaignInfo>, // 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<String>,
|
||||||
|
saved_campaign: Option<CampaignInfo>,
|
||||||
pending: Option<(u32, Req)>,
|
pending: Option<(u32, Req)>,
|
||||||
save_name: String,
|
save_name: String,
|
||||||
login_name: String,
|
login_name: String,
|
||||||
@@ -127,6 +170,13 @@ impl Cloud {
|
|||||||
list: Vec::new(),
|
list: Vec::new(),
|
||||||
listed: false,
|
listed: false,
|
||||||
users: Vec::new(),
|
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)),
|
pending: Some((id, Req::Health)),
|
||||||
save_name: String::new(),
|
save_name: String::new(),
|
||||||
login_name: String::new(),
|
login_name: String::new(),
|
||||||
@@ -326,7 +376,117 @@ impl Cloud {
|
|||||||
self.refresh();
|
self.refresh();
|
||||||
CloudEvent::Deleted
|
CloudEvent::Deleted
|
||||||
}
|
}
|
||||||
|
Req::CampaignList => {
|
||||||
|
if !ok {
|
||||||
|
return self.campaign_failed(res.status);
|
||||||
}
|
}
|
||||||
|
match serde_json::from_slice::<Vec<CampaignInfo>>(&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::<Vec<PublicCampaignInfo>>(&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::<CampaignInfo>(&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<String> {
|
||||||
|
self.loaded_campaign.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the entry of the campaign stored by save_campaign()
|
||||||
|
pub fn take_saved_campaign(&mut self) -> Option<CampaignInfo> {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,4 +534,12 @@ mod tests {
|
|||||||
assert_eq!(duration_string(3723.0), "1:02:03");
|
assert_eq!(duration_string(3723.0), "1:02:03");
|
||||||
assert_eq!(encode("Wave 3 – ä"), "Wave%203%20%E2%80%93%20%C3%A4");
|
assert_eq!(encode("Wave 3 – ä"), "Wave%203%20%E2%80%93%20%C3%A4");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn campaign_lists_parse() {
|
||||||
|
let own: Vec<CampaignInfo> = 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<PublicCampaignInfo> = 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user