mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 12:56:47 -06:00
276 lines
13 KiB
Rust
276 lines
13 KiB
Rust
use super::types::{Bind, HostOptions, NetReply, NetRequest, ONLINE_OFF, PublishOptions, Role, SessionId, Share, Shared, SharedLink, Status, Via};
|
|
use super::{
|
|
Access, Admin, AdminReply, ClientMsg, EngineError, HostInfo, IpAddr, Live, Result, Session, Value, bad, code, display_name, get, json,
|
|
req_string, string,
|
|
};
|
|
|
|
/// What a server says about its hosting, made safe to show: no control characters, short, and a
|
|
/// terms address only when it is a web page (`http(s)://`).
|
|
pub(super) fn clean_hosting(mut info: HostInfo) -> HostInfo {
|
|
let clean = |s: &str, n: usize| s.trim().chars().filter(|c| !c.is_control()).take(n).collect::<String>();
|
|
info.operator = clean(&info.operator, 120);
|
|
info.terms = clean(&info.terms, 500);
|
|
let web = info.terms.starts_with("https://") || info.terms.starts_with("http://");
|
|
if !web || info.terms.contains(char::is_whitespace) {
|
|
info.terms.clear();
|
|
}
|
|
info
|
|
}
|
|
|
|
/// A server's hosting as JSON (`collab.peers`, `collab.serverInfo`).
|
|
pub fn hosting_json(h: &HostInfo) -> Value {
|
|
json!({
|
|
"open": h.open,
|
|
"operator": h.operator,
|
|
"terms": h.terms,
|
|
"idleDays": (h.idle_secs > 0).then(|| (h.idle_secs as f64 / 86_400.0 * 100.0).round() / 100.0),
|
|
"maxFileMb": h.max_file_mb,
|
|
"expires": h.expires,
|
|
})
|
|
}
|
|
|
|
pub(super) fn unavailable(cmd: &str) -> EngineError {
|
|
EngineError::Disabled(
|
|
cmd.to_string(),
|
|
"live sessions aren't available here (the desktop app and the MCP server host and join them; the web build joins)".into(),
|
|
)
|
|
}
|
|
|
|
pub(super) fn host(s: &mut Session, p: &Value) -> Result<Value> {
|
|
start_host(s, "collab.host", p, Bind::Local, false)?;
|
|
Ok(s.live_summary())
|
|
}
|
|
|
|
/// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with
|
|
/// a secret a join code can carry): the new session.
|
|
fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result<SessionId> {
|
|
let port = match get(p, "port") {
|
|
None => None,
|
|
Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(cmd, "`port` must be a whole number from 0 to 65535"))?),
|
|
};
|
|
let bind = match string(p, "bind").as_deref().map(str::trim) {
|
|
None => bind,
|
|
Some("local") => Bind::Local,
|
|
Some("lan") => Bind::Lan,
|
|
Some(other) => other.parse::<IpAddr>().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?,
|
|
};
|
|
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
|
|
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short };
|
|
let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?;
|
|
s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
|
|
Ok(id)
|
|
}
|
|
|
|
pub(super) fn publish(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const CMD: &str = "collab.publish";
|
|
let server = req_string(CMD, p, "server")?;
|
|
start_publish(s, CMD, &server, p, false)?;
|
|
Ok(s.live_summary())
|
|
}
|
|
|
|
/// Start publishing the open file to `server` for command `cmd` (`share`: with links and join
|
|
/// codes for others too): the new session.
|
|
fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: bool) -> Result<SessionId> {
|
|
let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
|
|
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
|
|
let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::<String>()).filter(|n| !n.is_empty());
|
|
let me = display_name(&s.prefs);
|
|
let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, share };
|
|
let (id, ()) = s.live.start(cmd, |l, id| l.publish(id, opts).map_err(|e| bad(cmd, e)))?;
|
|
// This tab becomes the server file's synced document once it is there.
|
|
s.live.open(Live { id, status: Status::Connecting, doc_uid: Some(uid), tab: Some(uid), server: server.trim().to_string(), ..Live::default() });
|
|
Ok(id)
|
|
}
|
|
|
|
/// Why a local network share has no view-only code.
|
|
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only code";
|
|
|
|
pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const CMD: &str = "collab.share";
|
|
let via = match string(p, "via").as_deref().map(str::trim) {
|
|
None | Some("online") => Via::Online,
|
|
Some("local") => Via::Local,
|
|
Some(_) => return Err(bad(CMD, "`via` must be \"online\" or \"local\"")),
|
|
};
|
|
let access = match string(p, "access") {
|
|
None => None,
|
|
Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?),
|
|
};
|
|
// A share of this file is there already: only which code is shown changes, or (the file is
|
|
// on the server, not shared at the moment) it gets new links and codes. Nothing is published
|
|
// twice.
|
|
if let Some(shared) = s.live().share.as_ref().filter(|_| s.live().status.active()).map(|sh| sh.via) {
|
|
if get(p, "via").is_some() && shared != via {
|
|
return Err(bad(
|
|
CMD,
|
|
"this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)",
|
|
));
|
|
}
|
|
if access == Some(Access::View) && shared == Via::Local {
|
|
return Err(bad(CMD, LOCAL_EDITS));
|
|
}
|
|
if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
|
|
let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?;
|
|
shared_now(s, Some(clean_shared(links)));
|
|
}
|
|
if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) {
|
|
sh.access = a;
|
|
}
|
|
return Ok(s.live_summary());
|
|
}
|
|
let access = access.unwrap_or_default();
|
|
let mut server = String::new();
|
|
let id = match via {
|
|
Via::Local if access == Access::View => return Err(bad(CMD, LOCAL_EDITS)),
|
|
Via::Local => start_host(s, CMD, p, Bind::Lan, true)?,
|
|
Via::Online => {
|
|
server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string();
|
|
if server.is_empty() {
|
|
return Err(EngineError::Disabled(CMD.into(), ONLINE_OFF.into()));
|
|
}
|
|
start_publish(s, CMD, &server, p, true)?
|
|
}
|
|
};
|
|
if let Some(live) = s.live.sessions.get_mut(&id) {
|
|
// On the local network the session's own link is what others join with, and the code says
|
|
// what the link says: where the session is and its secret. (Online they arrive with the file.)
|
|
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| {
|
|
let code = code::Local::from_link(&i.link).map(|l| l.code());
|
|
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, view: None }
|
|
});
|
|
live.share = Some(Share { via, server, access, links });
|
|
}
|
|
Ok(s.live_summary())
|
|
}
|
|
|
|
/// What others join the open file's server file with changed: in its session and its record.
|
|
pub(super) fn shared_now(s: &mut Session, links: Option<Shared>) {
|
|
if let Some((server, file)) = s.live().server_file() {
|
|
s.online_shared(&server, &file, links);
|
|
}
|
|
}
|
|
|
|
/// Links and codes as a server gave them, with only codes that are codes (nine symbols).
|
|
pub(crate) fn clean_shared(mut links: Shared) -> Shared {
|
|
let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN);
|
|
clean(&mut links.edit);
|
|
links.view.as_mut().map(clean);
|
|
links
|
|
}
|
|
|
|
/// The request that changes who else gets into the server file the open file's session is its
|
|
/// owner's session of (`None`: it isn't one, or the file isn't there yet).
|
|
pub(super) fn share_request(s: &Session, revoke: Vec<String>, create: bool) -> Option<NetRequest> {
|
|
let live = s.live();
|
|
let (share, published) = (live.share.as_ref().filter(|s| s.via == Via::Online)?, live.published.as_ref()?);
|
|
if live.status != Status::Live {
|
|
return None;
|
|
}
|
|
// The token this session got when it published the file, else the recorded one.
|
|
let owner = published.owner.clone().or_else(|| s.online_owner(&share.server, &published.file)).unwrap_or_default();
|
|
Some(NetRequest::Share { server: share.server.clone(), file: published.file.clone(), owner, revoke, create })
|
|
}
|
|
|
|
/// Send `req` ([`NetRequest::Share`]): the new links, if it made any.
|
|
pub(crate) fn share_answer(s: &mut Session, req: NetRequest) -> Result<Option<Shared>> {
|
|
match s.net(req)?.0 {
|
|
NetReply::Share(Ok(links)) => Ok(links),
|
|
NetReply::Share(Err(why)) => Err(EngineError::Other(why)),
|
|
_ => Err(EngineError::Other("the server answered something else".into())),
|
|
}
|
|
}
|
|
|
|
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
|
|
/// its owner token (`collab.serverInfo`, `collab.manageFile`).
|
|
pub(super) fn hosting_request(
|
|
s: &mut Session,
|
|
cmd: &str,
|
|
server: &str,
|
|
file: &str,
|
|
owner: &str,
|
|
req: Option<Admin>,
|
|
) -> Result<(HostInfo, Option<AdminReply>)> {
|
|
let req = NetRequest::Hosting { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), req };
|
|
match s.net(req)?.0 {
|
|
// The server's refusals say why themselves.
|
|
NetReply::Hosting(Ok((_, Some(AdminReply::Failed { reason })))) | NetReply::Hosting(Err(reason)) => Err(EngineError::Other(reason)),
|
|
NetReply::Hosting(Ok((info, reply))) => Ok((clean_hosting(info), reply)),
|
|
_ => Err(EngineError::Other(format!("{cmd}: the server answered something else"))),
|
|
}
|
|
}
|
|
|
|
pub(super) fn server_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const CMD: &str = "collab.serverInfo";
|
|
let server = req_string(CMD, p, "server")?;
|
|
let (info, _) = hosting_request(s, CMD, server.trim(), "", "", None)?;
|
|
Ok(hosting_json(&info))
|
|
}
|
|
|
|
pub(super) fn manage_file(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const CMD: &str = "collab.manageFile";
|
|
let file = req_string(CMD, p, "file")?.trim().to_string();
|
|
let (server, owner) = crate::online::manager(s, CMD, p, &file)?;
|
|
let action = req_string(CMD, p, "action")?;
|
|
let mut renamed = None;
|
|
let req = match action.trim() {
|
|
"share" => return crate::online::set_shared(s, CMD, &server, &file, &owner, true),
|
|
"unshare" => return crate::online::set_shared(s, CMD, &server, &file, &owner, false),
|
|
"links" => Admin::Links { file: file.clone() },
|
|
"createLink" => {
|
|
let access = match string(p, "access").as_deref().map(str::trim) {
|
|
None | Some("view") => Access::View,
|
|
Some("edit") => Access::Edit,
|
|
Some(_) => return Err(bad(CMD, "`access` must be \"edit\" or \"view\"")),
|
|
};
|
|
Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() }
|
|
}
|
|
"revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() },
|
|
"rename" => {
|
|
let name = req_string(CMD, p, "name")?;
|
|
renamed = Some(name.clone());
|
|
Admin::Rename { file: file.clone(), name }
|
|
}
|
|
"remove" => Admin::Remove { file: file.clone() },
|
|
_ => return Err(bad(CMD, "`action` must be links, createLink, revoke, share, unshare, rename or remove")),
|
|
};
|
|
let changes = matches!(req, Admin::Rename { .. } | Admin::Remove { .. });
|
|
let reply = hosting_request(s, CMD, &server, &file, &owner, Some(req))?.1;
|
|
// The record follows what the server did.
|
|
if changes && reply == Some(AdminReply::Done) {
|
|
crate::online::managed(s, &server, &file, renamed.as_deref());
|
|
if renamed.is_none() {
|
|
crate::online::removed(s, &server, &file);
|
|
}
|
|
}
|
|
match reply {
|
|
Some(AdminReply::Links(links)) => Ok(json!({"links": links.iter().map(|l| json!({
|
|
"id": l.id, "access": l.access.id(), "label": l.label, "created": l.created
|
|
})).collect::<Vec<_>>()})),
|
|
// The link layer gives the new link whole in place of its token.
|
|
Some(AdminReply::Link(l)) => Ok(json!({"id": l.link.id, "access": l.link.access.id(), "link": l.token})),
|
|
Some(AdminReply::Done) => Ok(json!({"done": true})),
|
|
_ => Err(bad(CMD, "the server answered something else")),
|
|
}
|
|
}
|
|
|
|
pub(super) fn report(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const CMD: &str = "collab.report";
|
|
let reason: String = req_string(CMD, p, "reason")?.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REPORT).collect();
|
|
if reason.is_empty() {
|
|
return Err(bad(CMD, "say in a few words what is wrong with the file (`reason`)"));
|
|
}
|
|
let live = s.live();
|
|
if live.status != Status::Live || live.role() != Some(Role::Guest) {
|
|
return Err(bad(CMD, "join a file on a FigCraft server first: reports go to that server's operator"));
|
|
}
|
|
if live.hosting.is_none() {
|
|
return Err(bad(CMD, "this server doesn't take reports (it is older than this app); contact its operator another way"));
|
|
}
|
|
let id = live.id;
|
|
s.live.send(id, [ClientMsg::Report { reason }]);
|
|
Ok(json!({"sent": true}))
|
|
}
|
|
|
|
/// Longest report sent (characters).
|
|
pub const MAX_REPORT: usize = 500;
|