Skip to main content

tilo_app/framework/
share_link.rs

1//! Answers `share-link` and `share-invite-link` ([`tilo_resource::share`]).
2//!
3//! A link is a rendering of the address the action model already uses, under
4//! the web entry the room's host announces. The host is asked for a fresh
5//! locator every time, so a link never names an entry or a room the host no
6//! longer serves, and is refused when the host serves no web page rather than
7//! guessing one. See `docs/architecture/web-routing.md` ยง4.3.
8
9use tilo_actions::remote::{CatalogueQuery, ViewResourceQuery};
10use tilo_reference::link::{
11    AppId, HostOrigin, Link, LinkSecrets, LinkTarget, Mode, RecordSegment, ResourceAddress, Scope,
12    WebEntry,
13};
14use tilo_resource::share::{SHARE_INVITE_TYPE, SHARE_TYPE, ShareRequest};
15use ui_core::time::{Duration, Instant};
16use ui_core::{CatalogueCall, LocalState, RemoteActions, RemoteCall};
17
18/// How long a minting waits for the host's locator.
19const LOOKUP_TIMEOUT: Duration = Duration::from_secs(30);
20
21/// Why a host's answer cannot make a link.
22pub(crate) const NO_WEB_ENTRY: &str = "This workspace's host serves no web page";
23
24/// The record a root publishes from, as a provider names it.
25pub(crate) struct Publisher<'a> {
26    pub(crate) app: &'a str,
27    pub(crate) record: Option<&'a str>,
28    /// The root resource: a link to it names the record, not a resource.
29    pub(crate) root: &'a str,
30}
31
32/// What a link is minted for once the locator arrives.
33struct Minting {
34    provider: String,
35    app: AppId,
36    record: RecordSegment,
37    resource: Option<ResourceAddress>,
38    view: Option<String>,
39    mode: Mode,
40}
41
42struct Pending {
43    invocation: tilo_actions::InvocationId,
44    minting: Minting,
45    call: LocalState<CatalogueCall>,
46    started: Instant,
47    /// A person asked through a view: the URL goes to the clipboard.
48    copy: bool,
49}
50
51/// The root's minting of links: its own client for the session's action
52/// door, and the mintings waiting on it.
53pub(crate) struct ShareLinks {
54    remote: RemoteActions,
55    pending: Vec<Pending>,
56    /// Resources a view asked to copy a link to, in the order asked.
57    copy_requested: Vec<String>,
58}
59
60impl Default for ShareLinks {
61    fn default() -> Self {
62        Self {
63            remote: RemoteActions::current(),
64            pending: Vec::new(),
65            copy_requested: Vec::new(),
66        }
67    }
68}
69
70/// Whether `type_id` is one of the link operations.
71pub(crate) fn handles(type_id: &str) -> bool {
72    type_id == SHARE_TYPE || type_id == SHARE_INVITE_TYPE
73}
74
75/// What the minting answered and whether a person is to be told.
76pub(crate) struct Minted {
77    pub(crate) output: tilo_actions::ActionOutput,
78    pub(crate) copied: bool,
79}
80
81impl ShareLinks {
82    pub(crate) fn remote_mut(&mut self) -> &mut RemoteActions {
83        &mut self.remote
84    }
85
86    /// A view's control is about to invoke a link operation on `resource`:
87    /// its URL is copied, not only answered.
88    pub(crate) fn note_view_invocation(&mut self, invocation: &tilo_actions::ControlInvocation) {
89        if invocation.address.action == tilo_resource::share::SHARE_LINK
90            || invocation.address.action == tilo_resource::share::SHARE_INVITE_LINK
91        {
92            self.copy_requested
93                .push(invocation.address.resource.clone());
94        }
95    }
96
97    /// Start minting the link `payload` asks for.
98    ///
99    /// # Errors
100    /// Why no link can be made: bad arguments, no invite grant, or an
101    /// invitation this device cannot create.
102    pub(crate) fn start(
103        &mut self,
104        invocation: tilo_actions::InvocationId,
105        payload: &[u8],
106        publisher: &Publisher<'_>,
107        may_invite: bool,
108    ) -> Result<Option<Minted>, String> {
109        let request: ShareRequest =
110            tilo_actions::decode_json(payload).map_err(|error| error.to_string())?;
111        let copy = self
112            .copy_requested
113            .iter()
114            .position(|resource| *resource == request.resource)
115            .map(|at| self.copy_requested.remove(at))
116            .is_some();
117        if request.invite {
118            if !may_invite {
119                return Err("You may not invite people to this workspace".into());
120            }
121            // Invitations are created through the room's membership door,
122            // which only the shell reaches; an app cannot create one itself.
123            return Err(
124                "Invitations are created from the workspace's members list, not from an app".into(),
125            );
126        }
127        let minting = minting(&request, publisher)?;
128        let call = self.remote.catalogue_page(CatalogueQuery {
129            provider: Some(minting.provider.clone()),
130            view_resource: Some(ViewResourceQuery {
131                id: request.resource,
132                kind: request.kind,
133            }),
134            ..CatalogueQuery::default()
135        });
136        let pending = Pending {
137            invocation,
138            minting,
139            call,
140            started: ui_core::time::now(),
141            copy,
142        };
143        // A host in this process answers at once.
144        if let Some(result) = settled(&pending) {
145            return result.map(|url| Some(finish(&url, pending.copy)));
146        }
147        self.pending.push(pending);
148        Ok(None)
149    }
150
151    /// The update of a minting this answers, once it has one.
152    pub(crate) fn poll(
153        &mut self,
154        invocation: tilo_actions::InvocationId,
155        cancellation_requested: bool,
156    ) -> Option<(tilo_actions::InvocationUpdate, bool)> {
157        let at = self
158            .pending
159            .iter()
160            .position(|pending| pending.invocation == invocation)?;
161        let pending = self.pending.get(at)?;
162        if cancellation_requested {
163            self.pending.remove(at);
164            return Some((
165                tilo_actions::InvocationUpdate::Cancelled {
166                    invocation,
167                    message: None,
168                },
169                false,
170            ));
171        }
172        let result = settled(pending).or_else(|| {
173            (ui_core::time::now().saturating_duration_since(pending.started) >= LOOKUP_TIMEOUT)
174                .then(|| Err("The workspace's host did not answer".to_owned()))
175        })?;
176        let pending = self.pending.remove(at);
177        Some(match result {
178            Ok(url) => {
179                let minted = finish(&url, pending.copy);
180                (
181                    tilo_actions::InvocationUpdate::Succeeded {
182                        invocation,
183                        output: minted.output,
184                    },
185                    minted.copied,
186                )
187            }
188            Err(message) => (
189                tilo_actions::InvocationUpdate::Failed {
190                    invocation,
191                    error: tilo_actions::ActionFailure {
192                        code: "share_link".into(),
193                        message,
194                        details: None,
195                        retryable: true,
196                    },
197                },
198                false,
199            ),
200        })
201    }
202}
203
204/// The URL `pending`'s host answer makes, once it has answered.
205fn settled(pending: &Pending) -> Option<Result<String, String>> {
206    pending.call.with(|call| match call {
207        RemoteCall::Pending => None,
208        RemoteCall::Refused(error) => Some(Err(format!(
209            "The workspace's host would not look the resource up: {error}"
210        ))),
211        RemoteCall::Done(page) => Some(render(&pending.minting, page)),
212    })
213}
214
215/// The output for `url`, copied when a person asked through a view.
216fn finish(url: &str, copy: bool) -> Minted {
217    let copied = copy && ui_core::try_clipboard_copy(url);
218    Minted {
219        output: tilo_actions::ActionOutput {
220            value: Some(if copy {
221                serde_json::json!({ "url": url, "copied": copied })
222            } else {
223                serde_json::json!({ "url": url })
224            }),
225            resources: Vec::new(),
226        },
227        copied,
228    }
229}
230
231/// What `request` links to, from the record `publisher` publishes.
232fn minting(request: &ShareRequest, publisher: &Publisher<'_>) -> Result<Minting, String> {
233    let app = AppId::new(publisher.app).map_err(|error| error.to_string())?;
234    let record =
235        RecordSegment::new(publisher.record.unwrap_or("")).map_err(|error| error.to_string())?;
236    let provider = format!("record:{}:{}", app.as_str(), record.provider_part());
237    let args = &request.args;
238    let text = |name: &str| args.get(name).and_then(serde_json::Value::as_str);
239    let view = match text("view") {
240        Some(id) => request
241            .views
242            .iter()
243            .find(|view| view.id == id)
244            .ok_or_else(|| format!("This has no view `{id}`"))?,
245        None => request
246            .views
247            .iter()
248            .find(|view| view.primary)
249            .or_else(|| request.views.first())
250            .ok_or("This has no view to link to")?,
251    };
252    let offset = |name: &str| -> Result<Option<usize>, String> {
253        args.get(name)
254            .filter(|value| !value.is_null())
255            .map(|value| {
256                value
257                    .as_u64()
258                    .and_then(|offset| usize::try_from(offset).ok())
259                    .ok_or_else(|| format!("{name} is not a position in the text"))
260            })
261            .transpose()
262    };
263    let span = match (offset("start")?, offset("end")?) {
264        (None, None) => None,
265        (Some(start), Some(end)) => {
266            if !view.range {
267                return Err(format!("The view `{}` does not open at a range", view.id));
268            }
269            let unit = match text("unit").unwrap_or("chars") {
270                "chars" => tilo_reference::SpanUnit::Chars,
271                "bytes" => tilo_reference::SpanUnit::Bytes,
272                other => return Err(format!("Unknown unit `{other}`")),
273            };
274            Some(tilo_reference::Span::new(start, end, unit))
275        }
276        _ => return Err("A range needs both start and end".into()),
277    };
278    let resource =
279        (request.resource != publisher.root || span.is_some()).then(|| ResourceAddress {
280            kind: request.kind.clone(),
281            id: request.resource.clone(),
282            span,
283        });
284    let mode = match text("mode").unwrap_or("app") {
285        "app" => Mode::App,
286        "desktop" => Mode::Desktop,
287        other => return Err(format!("Unknown mode `{other}`")),
288    };
289    Ok(Minting {
290        provider,
291        app,
292        record,
293        resource,
294        view: (!view.primary).then(|| view.id.clone()),
295        mode,
296    })
297}
298
299/// The link `minting` names under the web entry the host announced on `page`.
300fn render(minting: &Minting, page: &tilo_actions::remote::CataloguePage) -> Result<String, String> {
301    let locator = page
302        .locator
303        .as_ref()
304        .ok_or("The workspace's host did not say where the resource is")?;
305    render_at(minting, locator)
306}
307
308/// The link to the resource `request` names in `app`'s `record`, for a
309/// room's host that answers `share-link` itself on the records it runs
310/// headless: the link an app's root mints, under the host's own `locator`.
311///
312/// # Errors
313/// Why there is no link: the request's arguments, an invitation (made from
314/// the members list), or a host that serves no web page.
315pub fn host_link(
316    request: &ShareRequest,
317    app: &str,
318    record: &str,
319    locator: &tilo_actions::remote::ResourceViewLocator,
320) -> Result<String, String> {
321    if request.invite {
322        return Err(
323            "Invitations are created from the workspace's members list, not from an app".into(),
324        );
325    }
326    // The host does not know which resource is the record's root, so its
327    // links always name the resource, which opens the same place.
328    let publisher = Publisher {
329        app,
330        record: Some(record),
331        root: "",
332    };
333    render_at(&minting(request, &publisher)?, locator)
334}
335
336fn render_at(
337    minting: &Minting,
338    locator: &tilo_actions::remote::ResourceViewLocator,
339) -> Result<String, String> {
340    if locator.provider != minting.provider {
341        return Err("The workspace's host answered for another record".into());
342    }
343    let entry = locator.web_entry.as_deref().ok_or(NO_WEB_ENTRY)?;
344    let entry = WebEntry::new(entry).map_err(|error| error.to_string())?;
345    let room = locator
346        .room
347        .parse()
348        .map_err(|_| "The workspace's host named no room a link can carry")?;
349    let host = HostOrigin::new(&locator.origin).map_err(|error| error.to_string())?;
350    let target = LinkTarget {
351        scope: Scope::Room {
352            host,
353            room,
354            slug: None,
355        },
356        app: Some(minting.app.clone()),
357        record: Some(minting.record.clone()),
358        resource: minting.resource.clone(),
359        view: minting.view.clone(),
360    };
361    Link {
362        mode: minting.mode,
363        target,
364        secrets: LinkSecrets::default(),
365    }
366    .to_url(&entry)
367    .map_err(|error| error.to_string())
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use tilo_actions::remote::{CataloguePage, ResourceViewLocator};
374    use tilo_resource::share::SharedView;
375
376    const ROOM: &str = "6f1c2b9e-0d4a-4c1e-9b7a-2f3d1e5c8a90";
377
378    fn request(args: &serde_json::Value) -> ShareRequest {
379        ShareRequest {
380            resource: "chat.message:01JA".into(),
381            kind: "chat.message".into(),
382            views: vec![
383                SharedView {
384                    id: "message".into(),
385                    primary: true,
386                    range: true,
387                },
388                SharedView {
389                    id: "thread".into(),
390                    primary: false,
391                    range: false,
392                },
393            ],
394            invite: false,
395            args: args.as_object().cloned().unwrap_or_default(),
396        }
397    }
398
399    fn page(entry: Option<&str>) -> CataloguePage {
400        CataloguePage {
401            locator: Some(ResourceViewLocator {
402                origin: "https://quinten.tilo.cloud".into(),
403                room: ROOM.into(),
404                host_device: "ab".repeat(32),
405                provider: "record:so.tilo.chat:".into(),
406                web_entry: entry.map(str::to_owned),
407            }),
408            ..CataloguePage::default()
409        }
410    }
411
412    const CHAT: Publisher<'static> = Publisher {
413        app: "so.tilo.chat",
414        record: Some("@tilo:package-primary"),
415        root: "chat",
416    };
417
418    fn url(args: &serde_json::Value) -> Result<String, String> {
419        render(
420            &minting(&request(args), &CHAT)?,
421            &page(Some("https://tilo.so/")),
422        )
423    }
424
425    #[test]
426    fn links_render_the_grammar_under_the_announced_entry() {
427        assert_eq!(
428            url(&serde_json::json!({})).unwrap(),
429            format!(
430                "https://tilo.so/app/#/@quinten.tilo.cloud/w/{ROOM}/so.tilo.chat/-/chat.message:01JA"
431            )
432        );
433        assert_eq!(
434            url(&serde_json::json!({"start": 6, "end": 9, "mode": "desktop"})).unwrap(),
435            format!(
436                "https://tilo.so/#/@quinten.tilo.cloud/w/{ROOM}/so.tilo.chat/-/chat.message:01JA?chars=6-9"
437            )
438        );
439        assert!(
440            url(&serde_json::json!({"view": "thread"}))
441                .unwrap()
442                .ends_with("chat.message:01JA?view=thread")
443        );
444    }
445
446    #[test]
447    fn what_cannot_be_linked_is_refused_with_its_reason() {
448        assert!(url(&serde_json::json!({"view": "thread", "start": 1, "end": 2})).is_err());
449        assert!(url(&serde_json::json!({"start": 1})).is_err());
450        assert!(url(&serde_json::json!({"view": "nope"})).is_err());
451        let minting = minting(&request(&serde_json::json!({})), &CHAT).unwrap();
452        assert_eq!(render(&minting, &page(None)).unwrap_err(), NO_WEB_ENTRY);
453        let mut other = page(Some("https://tilo.so/"));
454        if let Some(locator) = other.locator.as_mut() {
455            locator.provider = "record:so.tilo.notes:".into();
456        }
457        assert!(render(&minting, &other).is_err());
458    }
459
460    #[test]
461    fn a_link_to_the_root_names_the_record() {
462        let mut root = request(&serde_json::json!({}));
463        root.resource = "chat".into();
464        let minting = minting(&root, &CHAT).unwrap();
465        assert_eq!(
466            render(&minting, &page(Some("https://tilo.so/"))).unwrap(),
467            format!("https://tilo.so/app/#/@quinten.tilo.cloud/w/{ROOM}/so.tilo.chat/-")
468        );
469    }
470}