From d015436b13c0e803e1e487ffbc2c9366bebbeb80 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Fri, 30 Oct 2026 17:39:36 +0100 Subject: [PATCH] M9: team links the list no longer hands out are removed at startup; a team link is made before the list changes --- apps/figcraft-server/src/store.rs | 1 + apps/figcraft-server/src/store/team.rs | 24 ++++++++++++++++++++++- apps/figcraft-server/src/team_tests.rs | 27 +++++++++++++++++++++++++- docs/collab-protocol.md | 3 ++- docs/server.md | 4 +++- 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 04e9e3adc..6ca994b62 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -431,6 +431,7 @@ impl Store { pub fn open(root: &Path, cfg: StoreConfig) -> Result { let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?; store.sweep(); + store.drop_stale_team_links(); Ok(store) } diff --git a/apps/figcraft-server/src/store/team.rs b/apps/figcraft-server/src/store/team.rs index b59f83c5b..24524e3a6 100644 --- a/apps/figcraft-server/src/store/team.rs +++ b/apps/figcraft-server/src/store/team.rs @@ -51,13 +51,14 @@ impl Store { if let Some(made) = made { return Ok(made); } + // Made before the list changes: a failure leaves the links as they were. + let (mut link, token) = new_link(access, &label(access), now())?; let before = entry.links.clone(); entry.links.retain(|l| l.team.is_none()); if entry.links.len() >= max { entry.links = before; return Err(format!("a file has at most {max} links: revoke one first")); } - let (mut link, token) = new_link(access, &label(access), now())?; link.team = Some(TeamToken(token.clone())); entry.links.push(link.clone()); if let Err(e) = write_json(&path, &entry.links) { @@ -68,6 +69,27 @@ impl Store { Ok(NewLink { link: link.info(file), token }) } + /// At startup: the team links the list doesn't hand out now (it is off, or gives another + /// access) are removed for good, so setting `team_files` back doesn't bring them back. Nobody + /// keeps a team link's token for later (a team list link names none), so nothing is lost: the + /// next join makes a new one. One that can't be removed stays refused at the door, and the + /// operator is told. + pub(super) fn drop_stale_team_links(&mut self) { + let team = self.cfg.hosting.team; + let stale = |l: &super::Link| l.team.is_some() && team != Some(l.access); + let files: Vec = self.files.iter().filter(|(_, e)| e.links.iter().any(stale)).map(|(id, _)| id.clone()).collect(); + for file in files { + let path = self.dir(&file).join(LINKS); + let Some(entry) = self.files.get_mut(&file) else { continue }; + entry.links.retain(|l| !stale(l)); + if let Err(e) = write_json(&path, &entry.links) { + self.note(format!( + "file {file}: its old team links couldn't be removed ({e}); they are refused, but would work again if team_files were set back to their access" + )); + } + } + } + /// `file` stopped being shared (a link was revoked): revoke its team links too. The ids /// revoked (whoever joined with them is disconnected); none while it is still shared. pub(super) fn team_unshared(&mut self, file: &str) -> Vec { diff --git a/apps/figcraft-server/src/team_tests.rs b/apps/figcraft-server/src/team_tests.rs index 22b02a50c..fbe11935b 100644 --- a/apps/figcraft-server/src/team_tests.rs +++ b/apps/figcraft-server/src/team_tests.rs @@ -171,10 +171,35 @@ fn turning_the_list_off_or_changing_its_access_refuses_old_team_links() { pump(&mut [&mut b], "Bob in, view only", |s| live(s[0]) && s[0].live_summary()["access"] == "view"); drop(b); drop(server); - // Off: the list is gone and its links with it. + // Off: the list is gone and its links with it, also from the file's links. let server = crate::start(&Config { open_publish: true, ..config(dir.path()) }).unwrap(); let mut b = join(&invite(server.addr(), &file, &view), "Bob"); pump(&mut [&mut b], "the team link refused", |s| refused(s[0])); + drop(b); + drop(server); + assert!(!std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap().contains(r#""team""#)); + // On again with the old access: the old links stay refused; the next join gets a new one. + let server = crate::start(&team(dir.path(), "view")).unwrap(); + let mut b = join(&invite(server.addr(), &file, &view), "Bob"); + pump(&mut [&mut b], "the old view team link still refused", |s| refused(s[0])); + assert_ne!(team_link(server.addr(), &file).unwrap(), view); +} + +#[test] +fn a_team_link_never_handed_out_again_stays_refused_after_the_setting_goes_back() { + let dir = TempDir::new("team-back"); + let mut server = crate::start(&team(dir.path(), "edit")).unwrap(); + let (a, file) = share(server.addr(), "Homepage"); + let edit = team_link(server.addr(), &file).unwrap(); + drop(a); + server.stop(); + // View only for a while, and nobody asks for the view team link… + drop(crate::start(&team(dir.path(), "view")).unwrap()); + // …then edit again: the edit team link of before doesn't come back. + let server = crate::start(&team(dir.path(), "edit")).unwrap(); + let mut b = join(&invite(server.addr(), &file, &edit), "Bob"); + pump(&mut [&mut b], "the old edit team link refused", |s| refused(s[0])); + assert_ne!(team_link(server.addr(), &file).unwrap(), edit); } #[test] diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index d12352389..2f8b15a75 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -684,7 +684,8 @@ resuming with its offline edits, and leaving the session going back to snapshot - **Revoking.** Revoking a link (`Admin revoke`) that leaves the file without a coded link revokes its team links too, in the same answer (`Handled::revoked` names every link revoked), so whoever joined with one is disconnected. Admission refuses a team link while the list is off, gives - another access, or the file isn't shared, whatever `links.json` still holds. + another access, or the file isn't shared, whatever `links.json` still holds, and the server + removes such team links when it starts. *Built* (document sync between apps): diff --git a/docs/server.md b/docs/server.md index 92918d344..a79db19bd 100644 --- a/docs/server.md +++ b/docs/server.md @@ -193,7 +193,9 @@ someone in, so keep it off on a server open to anyone. `#join=`, which opens the file in the browser. - **Stopping.** When the last of a file's links with a code is revoked (Stop sharing does), its team link is revoked with it and whoever joined with it is disconnected, as with the other - links. Turning the list off, or changing `team_files`, refuses the old team links at once. + links. Turning the list off, or changing `team_files`, refuses the old team links at once, and + the next start removes them for good (setting it back doesn't bring them back; the next join + makes a new one). - **What the server keeps.** Unlike other links, a team link's token is kept readable in the file's `links.json`, so the server can hand it out: as private as the server key beside it. - **Upgrade note.** An app older than the team list shows no list and can't open its links (it