mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
A file put on a server without the server's key stays its owner's after its tab closes or the app quits. The app records it with the preferences (server, id, name, owner token, the owner's links, what others join with; at most 200; validated when read back) and never shows the token: not in command results, ui.inspect, logs or debug output. - Home > Online lists your online files: open one as its owner (a new tab, or its tab comes to the front), start and stop sharing, copy the code or link, rename on the server, remove from the server (confirmed), forget here. One request per server when the section opens; offline and gone states. - collab.files, collab.openFile, collab.forgetFile; collab.manageFile takes its server and token from the record and gains share / unshare. - Server: Admin owned (a compatible addition within protocol version 2) says what became of the files a list of owner tokens owns. Older servers leave the list as recorded. - Tabs mark files in a live session (shared, or open only to you); toasts about a file that isn't in front name it; after a crash a recorded file reopens from its server as its owner. - Draft catalogs (de, fr, ja, ko, pt-BR, zh-Hans) translate the 71 strings and 5 command labels they lacked and the new ones; es and es-419 follow. - Docs: collab-protocol, server, mcp, control-protocol, parity pages, gaps, ROADMAP.
195 lines
11 KiB
Rust
195 lines
11 KiB
Rust
//! Your online files against a real server: a file shared online is recorded with its owner
|
|
//! token; after a restart of the app the server says what became of it (`Admin::Owned`, one
|
|
//! request for all the files), it opens again as its owner in step with everyone who joined by
|
|
//! code, its sharing stops and starts from the record, and removing it takes it off the server's
|
|
//! disk. The request itself: only a file's own token learns about it, junk never hurts, and an
|
|
//! owner's question opens nothing (a file isn't kept alive by being asked about).
|
|
|
|
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, MAX_OWNED, OpenHosting, PROTOCOL, ServerMsg};
|
|
use figcraft_engine::Prefs;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::config::Config;
|
|
use crate::test_util::{Raw, TempDir, config, live, pump, same, session};
|
|
|
|
fn open(dir: &std::path::Path) -> Config {
|
|
Config { open_publish: true, ..config(dir) }
|
|
}
|
|
|
|
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
|
|
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
|
}
|
|
|
|
/// An open hosting connection that names no file.
|
|
fn hosting(addr: std::net::SocketAddr) -> Raw {
|
|
let mut raw = Raw::connect(addr);
|
|
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() }));
|
|
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
|
raw
|
|
}
|
|
|
|
fn owned(raw: &mut Raw, files: Vec<(String, String)>) -> AdminReply {
|
|
raw.send(&ClientMsg::Admin(Admin::Owned { files }));
|
|
match raw.next() {
|
|
ServerMsg::Admin(r) => *r,
|
|
other => panic!("answered {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Ada shares her open file online through the server at `url`: the app with the file live.
|
|
fn share(url: &str) -> figcraft_engine::Session {
|
|
let mut a = session("Ada");
|
|
ex(&mut a, "pref.publishServer", json!({"url": url}));
|
|
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
|
|
ex(&mut a, "collab.share", json!({}));
|
|
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some()));
|
|
a
|
|
}
|
|
|
|
/// The app quits (its sessions end with it); the next run has only the preferences.
|
|
fn restart(a: figcraft_engine::Session, name: &str) -> figcraft_engine::Session {
|
|
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&a.prefs).unwrap()).unwrap();
|
|
drop(a);
|
|
let mut next = session(name);
|
|
next.prefs = prefs;
|
|
next
|
|
}
|
|
|
|
#[test]
|
|
fn a_shared_file_is_its_owners_again_after_the_app_restarts() {
|
|
let dir = TempDir::new("owned-restart");
|
|
let mut server = crate::start(&open(dir.path())).unwrap();
|
|
let url = format!("ws://{}/", server.addr());
|
|
let a = share(&url);
|
|
let code = a.live_summary()["code"].as_str().unwrap().to_string();
|
|
let file = a.prefs.online_files[0].file.clone();
|
|
let owner = a.prefs.online_files[0].owner.clone();
|
|
assert_eq!(a.live().published.as_ref().and_then(|p| p.owner.clone()), Some(owner.clone()));
|
|
// The app quits. The next run knows the file from its preferences alone.
|
|
let mut a = restart(a, "Ada");
|
|
let list = ex(&mut a, "collab.files", json!({}));
|
|
assert_eq!((&list["files"][0]["file"], &list["files"][0]["state"], &list["files"][0]["code"]), (&json!(file), &json!("unknown"), &json!(code)));
|
|
// The server says it is there, and still shared.
|
|
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
|
|
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"], &list["problems"]), (&json!("online"), &json!(true), &json!([])));
|
|
assert!(list["files"][0]["expires"].as_u64().is_some_and(|e| e > crate::store::now()));
|
|
assert!(!list.to_string().contains(&owner));
|
|
// Open it: the owner is in the file, synced, in a tab of its own.
|
|
let opened = ex(&mut a, "collab.openFile", json!({"file": file}));
|
|
assert!(!opened.to_string().contains(&owner));
|
|
pump(&mut [&mut a], "the owner back in", |s| live(s[0]));
|
|
assert_eq!(a.docs.len(), 2);
|
|
assert!(!a.live_summary().to_string().contains(&owner), "the token stays in the record");
|
|
// Someone joins with the code given out before the restart, and sees the owner's edits.
|
|
let mut b = session("Bo");
|
|
ex(&mut b, "pref.publishServer", json!({"url": url}));
|
|
ex(&mut b, "collab.join", json!({"code": code}));
|
|
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
|
ex(&mut a, "shape.ellipse", json!({"x": 100, "y": 100, "width": 20, "height": 20}));
|
|
pump(&mut [&mut a, &mut b], "the edit at Bo's", |s| same(s[0].doc().unwrap(), s[1].doc().unwrap()));
|
|
// Stop sharing from the list (no tab needed): Bo is out, the owner stays, the code is dead.
|
|
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false);
|
|
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active() && live(s[0]));
|
|
assert_eq!(a.live_summary()["share"]["shared"], false, "the open tab follows");
|
|
let mut c = session("Cy");
|
|
ex(&mut c, "pref.publishServer", json!({"url": url}));
|
|
ex(&mut c, "collab.join", json!({"code": code}));
|
|
pump(&mut [&mut c], "the old code refused", |s| matches!(s[0].live().status, figcraft_engine::live::Status::Failed { .. }));
|
|
// Start again: a new code for the same file.
|
|
let again = ex(&mut a, "collab.share", json!({}));
|
|
let new_code = again["code"].as_str().unwrap().to_string();
|
|
assert_ne!(new_code, code);
|
|
assert_eq!(ex(&mut a, "collab.files", json!({}))["files"][0]["code"], new_code);
|
|
let mut d = session("Di");
|
|
ex(&mut d, "pref.publishServer", json!({"url": url}));
|
|
ex(&mut d, "collab.join", json!({"code": new_code}));
|
|
pump(&mut [&mut a, &mut d], "Di in with the new code", |s| live(s[1]));
|
|
// Rename, then remove: the file leaves the server's disk and the list; the tab's session ends.
|
|
ex(&mut a, "collab.manageFile", json!({"file": file, "action": "rename", "name": "Landing"}));
|
|
assert_eq!(ex(&mut a, "collab.files", json!({"refresh": true}))["files"][0]["name"], "Landing");
|
|
assert!(dir.path().join("files").join(&file).exists());
|
|
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "remove"}))["done"], true);
|
|
assert!(!dir.path().join("files").join(&file).exists(), "gone from the data directory");
|
|
assert!(a.prefs.online_files.is_empty() && !a.live().status.active() && a.docs.len() == 2, "the copy stays open");
|
|
server.stop();
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_that_expired_or_was_removed_elsewhere_shows_as_gone() {
|
|
let dir = TempDir::new("owned-gone");
|
|
let cfg = open(dir.path());
|
|
let mut server = crate::start(&cfg).unwrap();
|
|
let url = format!("ws://{}/", server.addr());
|
|
let mut a = share(&url);
|
|
let file = a.prefs.online_files[0].file.clone();
|
|
ex(&mut a, "collab.leave", json!({}));
|
|
let mut a = restart(a, "Ada");
|
|
// The operator removes it.
|
|
crate::cli::run(&crate::cli::Cli { config: cfg.clone(), command: crate::cli::Command::RemoveFile { file: file.clone() } }).unwrap();
|
|
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
|
|
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"]), (&json!("gone"), &json!(false)));
|
|
assert!(a.execute("collab.openFile", &json!({"file": file})).unwrap_err().to_string().contains("no longer on its server"));
|
|
assert_eq!(ex(&mut a, "collab.forgetFile", json!({"file": file}))["onServer"], false);
|
|
// The server stops: the list says it couldn't be reached and keeps what it has.
|
|
let mut b = share(&url);
|
|
ex(&mut b, "collab.leave", json!({}));
|
|
server.stop();
|
|
let list = ex(&mut b, "collab.files", json!({"refresh": true}));
|
|
assert_eq!((&list["files"][0]["state"], &list["problems"][0]["state"]), (&json!("online"), &json!("offline")));
|
|
assert_eq!(list["files"].as_array().unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_files_own_token_learns_about_it() {
|
|
let dir = TempDir::new("owned-ask");
|
|
let cfg = open(dir.path());
|
|
let mut server = crate::start(&cfg).unwrap();
|
|
let addr = server.addr();
|
|
let a = share(&format!("ws://{addr}/"));
|
|
let (file, owner) = (a.prefs.online_files[0].file.clone(), a.prefs.online_files[0].owner.clone());
|
|
let opened_before = std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap();
|
|
let mut raw = hosting(addr);
|
|
let pair = |f: &str, o: &str| (f.to_string(), o.to_string());
|
|
let reply = owned(
|
|
&mut raw,
|
|
vec![pair(&file, &owner), pair(&file, "WrongTokenWrongToken00"), pair("nosuchfile", &owner), pair("", ""), pair("../../etc", "x")],
|
|
);
|
|
let AdminReply::Owned(list) = reply else { panic!("answered {reply:?}") };
|
|
assert_eq!(list.len(), 5, "one answer per pair, in order");
|
|
let first = list[0].as_ref().expect("its own token");
|
|
assert_eq!(first.name, "Untitled");
|
|
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
|
|
assert!(first.expires.is_some());
|
|
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
|
|
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
|
|
assert_eq!(owned(&mut raw, vec![]), AdminReply::Owned(vec![]));
|
|
let many = vec![pair(&file, &owner); MAX_OWNED + 1];
|
|
assert!(matches!(owned(&mut raw, many), AdminReply::Failed { reason } if reason.contains("at most")));
|
|
assert!(matches!(owned(&mut raw, vec![pair(&file, &owner); MAX_OWNED]), AdminReply::Owned(l) if l.len() == MAX_OWNED));
|
|
// Junk ids and tokens (long, control characters, other scripts) are just not theirs.
|
|
let junk = vec![pair(&"x".repeat(100_000), &"y".repeat(100_000)), pair("\u{0}\u{7}", "\u{1F4A5}"), pair(&file, &format!("{owner}\u{0}"))];
|
|
assert!(matches!(owned(&mut raw, junk), AdminReply::Owned(l) if l.iter().all(Option::is_none)));
|
|
// On that connection the token still manages nothing: it named no file when it opened.
|
|
raw.send(&ClientMsg::Admin(Admin::Remove { file: file.clone() }));
|
|
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { .. })));
|
|
assert!(dir.path().join("files").join(&file).exists());
|
|
// Asking opened nothing: when the file was last opened is as it was.
|
|
assert_eq!(std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap(), opened_before);
|
|
// A connection that manages one file with its token may ask too; the server key as well.
|
|
let mut one = Raw::connect(addr);
|
|
one.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: file.clone(), owner: owner.clone() }));
|
|
assert!(matches!(one.next(), ServerMsg::Hosting(_)));
|
|
assert!(matches!(owned(&mut one, vec![pair(&file, &owner)]), AdminReply::Owned(l) if l[0].is_some()));
|
|
drop(a);
|
|
server.stop();
|
|
}
|
|
|
|
#[test]
|
|
fn a_server_that_isnt_open_to_the_public_answers_that_nothing_is_owned() {
|
|
let dir = TempDir::new("owned-private");
|
|
let mut server = crate::start(&config(dir.path())).unwrap();
|
|
let mut raw = hosting(server.addr());
|
|
assert!(matches!(owned(&mut raw, vec![("f".into(), "t".into())]), AdminReply::Owned(l) if l == [None]));
|
|
server.stop();
|
|
}
|