mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 03:51:42 +00:00
M9: a local view-only share's link is the view link, not the edit link
`collab.share {via: "local", access: "view"}` showed the view-only code but
gave the session's own invite link as `link`, and that link edits: whoever
joined through it could change the host's file. A local share's `link`, top
level and in `sessions`, is now the one that goes with the code shown, and it
follows `collab.share {access}`. Online shares keep the owner's own link there,
as before.
Regression test: `code_tests::a_local_view_share_s_link_only_lets_people_look`
joins through that link over loopback and is welcomed as a viewer whose edit
never reaches the host.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2VLhC9d5v8VSsf4ng
This commit is contained in:
1 parent
a2be6632eb
commit
a05201e21f
7 files changed
+51
-7
No files matched your search
@@ -159,6 +159,34 @@ fn a_local_view_code_lets_people_look_and_not_edit() {
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active());
|
||||
}
|
||||
|
||||
/// A local network share's `link` is the one that goes with the code shown: started for looking
|
||||
/// only, whoever joins through it can't edit, and the session's line in `sessions` says the same.
|
||||
#[test]
|
||||
fn a_local_view_share_s_link_only_lets_people_look() {
|
||||
let (mut host, mut viewer) = (session("Hana"), session("Ada"));
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap();
|
||||
let link = shared["link"].as_str().unwrap().to_string();
|
||||
viewer.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the link's join", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
let summary = viewer.live_summary();
|
||||
assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)), "joined with {link}");
|
||||
let before = layers(&host);
|
||||
let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string();
|
||||
assert!(e.contains("View only"), "{e}");
|
||||
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1);
|
||||
assert_eq!(layers(&host), before + 1, "nothing of the viewer's reached the host");
|
||||
// The link and the code name the same way in, wherever the session is listed.
|
||||
let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!((&shared["link"], &shared["share"]["viewLink"]), (&json!(local.link()), &json!(local.link())));
|
||||
assert_eq!(shared["sessions"][0]["link"], shared["link"]);
|
||||
// Picking the edit code makes the link the edit one too.
|
||||
let edit = host.execute("collab.share", &json!({"access": "edit"})).unwrap();
|
||||
assert_eq!((&edit["link"], &edit["sessions"][0]["link"]), (&edit["share"]["editLink"], &edit["share"]["editLink"]));
|
||||
assert_ne!(edit["link"], shared["link"]);
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
}
|
||||
|
||||
/// An invite naming only `server` (what a join code is asked at).
|
||||
fn code_at(server: &Endpoint) -> Invite {
|
||||
Invite { server: server.clone(), file: String::new(), token: String::new() }
|
||||
|
||||
@@ -380,9 +380,12 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
|
||||
assert!(h.short && h.view && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
|
||||
}
|
||||
// Which code shows: the view code, then the edit code again (nothing is hosted twice).
|
||||
// The link goes with the code: a view-only code's link doesn't edit.
|
||||
let r = ex(&mut s, "collab.share", json!({"access": "view"}));
|
||||
assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view")));
|
||||
assert_eq!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], json!(code::grouped(&HOST.code())));
|
||||
assert_eq!((&r["link"], &r["sessions"][0]["link"]), (&json!(VIEW.link()), &json!(VIEW.link())));
|
||||
let r = ex(&mut s, "collab.share", json!({"access": "edit"}));
|
||||
assert_eq!((&r["code"], &r["link"]), (&json!(code::grouped(&HOST.code())), &json!(HOST.link())));
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "comment"})).contains("share it online"));
|
||||
assert_eq!(script.lock().unwrap().hosted.len(), 1);
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
|
||||
@@ -390,6 +393,7 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
|
||||
// Shared for looking only from the start: the edit code isn't the one shown.
|
||||
let r = ex(&mut s, "collab.share", json!({"via": "local", "access": "view"}));
|
||||
assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view")));
|
||||
assert_eq!((&r["link"], &r["sessions"][0]["link"]), (&json!(VIEW.link()), &json!(VIEW.link())), "the edit link isn't handed out");
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "comment"})).contains("share it online"));
|
||||
// Where to listen is the advanced part.
|
||||
|
||||
@@ -147,6 +147,17 @@ impl Live {
|
||||
self.info.as_ref().map(|i| i.role)
|
||||
}
|
||||
|
||||
/// The session's invite link as `collab.peers` gives it. A file shared on the local network
|
||||
/// gives the link that goes with the code shown: the session's own link is the edit way in,
|
||||
/// and a view-only share must not hand it out. Otherwise the session's own link (online, the
|
||||
/// owner's: `share` has the links for others).
|
||||
pub fn invite_link(&self) -> Option<&str> {
|
||||
match &self.share {
|
||||
Some(sh) if sh.via == Via::Local => sh.link(),
|
||||
_ => self.info.as_ref().map(|i| i.link.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop following. False when you weren't following anyone.
|
||||
fn unfollow(&mut self) -> bool {
|
||||
self.followed = None;
|
||||
|
||||
@@ -175,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.share",
|
||||
"Share with a join code",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (look only: their edits are refused): which code `code` is, server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` the session's own invite link (it edits: `share` has the link that goes with each code). Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols), and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: the edit and view-only ways in, each with its own secret (commentCode and commentLink null). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (look only: their edits are refused): which code `code` is, server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` the invite link to give with it (local: the one that goes with `code`, so a view-only code's link lets people look only; online: your own link to the file, which edits: `share` has the link that goes with each code). Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols), and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: the edit and view-only ways in, each with its own secret (commentCode and commentLink null). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
false,
|
||||
share
|
||||
),
|
||||
|
||||
@@ -295,7 +295,7 @@ impl Session {
|
||||
"reason": reason(o),
|
||||
"role": o.role(),
|
||||
"fileName": info.map(|i| i.file_name.as_str()),
|
||||
"link": info.map(|i| i.link.as_str()),
|
||||
"link": o.invite_link(),
|
||||
"peers": o.peers.len(),
|
||||
})
|
||||
})
|
||||
@@ -313,7 +313,7 @@ impl Session {
|
||||
"color": info.map(|i| i.color.as_str()),
|
||||
"fileName": info.map(|i| i.file_name.as_str()),
|
||||
"pages": info.map(|i| &i.pages),
|
||||
"link": info.map(|i| i.link.as_str()),
|
||||
"link": l.invite_link(),
|
||||
"url": info.map(|i| i.url.as_str()),
|
||||
"address": info.map(|i| i.address.as_str()),
|
||||
"network": info.map(|i| i.network),
|
||||
|
||||
@@ -603,8 +603,9 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
see every change and everyone's presence, the app makes the file read-only for them, and the
|
||||
session refuses any batch they send anyway, as a server does for its view links. The Share
|
||||
popover picks which code shows (Can edit / Can view, also before sharing starts, so the edit
|
||||
code need never be on screen; `collab.share {access}`); picking one later changes nothing for
|
||||
who already joined. There is no comment code on the local network. A host without an IPv4
|
||||
code need never be on screen; `collab.share {access}`, whose `link` is the one that goes with
|
||||
the code shown, so a view-only share never hands out the edit link); picking one later changes
|
||||
nothing for who already joined. There is no comment code on the local network. A host without an IPv4
|
||||
address gets no codes and shares its links.
|
||||
- **In the browser** a code joins like a link (the page asks the server over its own WebSocket,
|
||||
then opens the session's); it shares online through the server in Preferences as the desktop
|
||||
|
||||
@@ -26,7 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same, and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
|
||||
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link`, like its `code`, is the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
|
||||
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
|
||||
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
|
||||
|
||||
Reference in new issue
Block a user