share_page.rs
⎇
Raw
1//! Public share page: renders a shared item without login.
2
3use crate::i18n;
4use api_types::FileKind;
5use leptos::prelude::*;
6use wasm_bindgen_futures::spawn_local;
7
8use crate::api::{self, Me, Mode, RootInfo, ShareInfo, UserInfo};
9use crate::components::toast::provide_toast;
10use crate::editor::EditTarget;
11use crate::preview::{PreviewTarget, preview_kind};
12use crate::router::Location;
13use crate::views::browser::Browser;
14use crate::views::file_view::{FileView, UnsupportedTarget};
15use crate::views::workspace::FileWorkspace;
16
17#[derive(Clone, PartialEq)]
18enum SharePageState {
19 Loading,
20 NotFound,
21 Expired,
22 /// The share asks for a password. The server withholds even its name
23 /// until the password is entered, so nothing about it is known yet.
24 Locked,
25 Active,
26}
27
28/// The full-page view for a shared *file*: the share page *is* the file.
29/// The file sits at the (empty) path of its synthetic root.
30fn file_view_for_share(i: &ShareInfo) -> Option<FileView> {
31 let kind = i.kind?;
32 let root_id = i.root_id;
33 let name = i.name.clone();
34 let path = String::new();
35 match kind {
36 FileKind::Text => Some(FileView::Editor(EditTarget {
37 root_id,
38 path,
39 name,
40 readonly: !i.writable,
41 })),
42 k if preview_kind(k).is_some() => Some(FileView::Preview(
43 PreviewTarget {
44 root_id,
45 path,
46 name,
47 },
48 preview_kind(k).unwrap(),
49 )),
50 k => Some(FileView::Unsupported(UnsupportedTarget {
51 root_id,
52 path,
53 name,
54 kind: k,
55 })),
56 }
57}
58
59#[component]
60pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
61 let _ = provide_toast();
62 let (me, set_me) = signal(Option::<Me>::None);
63 let (info, set_info) = signal(Option::<ShareInfo>::None);
64 let (state, set_state) = signal(SharePageState::Loading);
65
66 // Apply a resolved share: it becomes the synthetic "me" every file call
67 // is scoped to. Shared by the initial resolve and by the unlock form.
68 let accept = Callback::new(move |i: ShareInfo| {
69 let mode = if i.writable { Mode::Rw } else { Mode::Ro };
70 set_me.set(Some(Me {
71 first_boot: false,
72 user: Some(UserInfo {
73 id: i.id,
74 name: "shared".to_string(),
75 is_admin: false,
76 single_click_open: false,
77 thumbnails: true,
78 language: None,
79 default_root_id: None,
80 auth_mode: Default::default(),
81 has_password: false,
82 }),
83 roots: vec![RootInfo {
84 id: i.root_id,
85 name: i.name.clone(),
86 path: "/".to_string(),
87 mode,
88 }],
89 allow_writable_shares: false,
90 // A share page cannot read the server config, so it asks and lets
91 // the 404 decide. The tile falls back to its icon either way.
92 thumbnails_available: true,
93 public_url: None,
94 }));
95 set_info.set(Some(i));
96 set_state.set(SharePageState::Active);
97 });
98
99 // Resolve the share once per token and scope all file API calls to it.
100 {
101 let tok = token.clone();
102 let set_state2 = set_state;
103 spawn_local(async move {
104 api::set_share_token(Some(&tok));
105 match api::resolve_share(&tok).await {
106 Ok(i) => accept.run(i),
107 // 401 means password protected, not missing or gone.
108 Err(e) => set_state2.set(match e.status() {
109 Some(410) => SharePageState::Expired,
110 Some(401) => SharePageState::Locked,
111 _ => SharePageState::NotFound,
112 }),
113 }
114 });
115 on_cleanup(move || api::set_share_token(None));
116 }
117
118 // The shared file-view plumbing (topbar, close/save/dirty, selection,
119 // dialogs, Escape). Closing a folder share returns to the listing;
120 // closing a file share re-opens the file itself (there is nothing to
121 // close back to, so the close button and Escape stay disabled as
122 // well).
123 let token_for_form = token.clone();
124
125 let ws = FileWorkspace::new(
126 me,
127 loc,
128 move || info.get().and_then(|i| file_view_for_share(&i)),
129 move || state.get() == SharePageState::Active,
130 );
131 let file_view = ws.file_view;
132 let open_file = ws.open_file;
133 let selected = ws.selected;
134 let set_selected = ws.set_selected;
135 let dialog = ws.dialog;
136 let set_dialog = ws.set_dialog;
137 let set_browser_refresh = ws.set_browser_refresh;
138 let topbar_ref = ws.topbar_ref;
139 let topbar_left = ws.topbar_left();
140 let topbar_right = ws.topbar_right();
141 let file_content = ws.file_content();
142 let overlays = ws.overlays();
143
144 // A file share opens straight into its viewer.
145 {
146 let info2 = info;
147 let set_fv = open_file;
148 Effect::new(move |_| {
149 if let Some(i) = info2.get()
150 && i.is_file
151 {
152 set_fv.set(file_view_for_share(&i));
153 }
154 });
155 }
156
157 view! {
158 <div
159 class=move || {
160 if file_view.get().is_some() {
161 "layout share-layout file-mode".to_string()
162 } else {
163 "layout share-layout".to_string()
164 }
165 }
166 >
167 <header class="topbar" node_ref=topbar_ref>
168 <div class="topbar-left">
169 {topbar_left}
170 </div>
171 <div class="topbar-right">
172 {topbar_right}
173 </div>
174 </header>
175 <main class="content" class:file-open=move || file_view.get().is_some()>
176 {move || match state.get() {
177 SharePageState::Loading => view! {
178 <div class="center-screen">
179 <p class="muted">{i18n::tr(i18n::k::LOADING_SHARE)}</p>
180 </div>
181 }
182 .into_view()
183 .into_any(),
184 SharePageState::NotFound => view! {
185 <div class="center-screen">
186 <h2>{i18n::tr(i18n::k::SHARE_NOT_FOUND)}</h2>
187 <p class="muted">{i18n::tr(i18n::k::SHARE_NOT_FOUND_MSG)}</p>
188 </div>
189 }
190 .into_view()
191 .into_any(),
192 SharePageState::Expired => view! {
193 <div class="center-screen">
194 <h2>{i18n::tr(i18n::k::SHARE_EXPIRED)}</h2>
195 <p class="muted">{i18n::tr(i18n::k::SHARE_EXPIRED_MSG)}</p>
196 </div>
197 }
198 .into_view()
199 .into_any(),
200 SharePageState::Locked => view! {
201 <UnlockForm token=token_for_form.clone() accept=accept/>
202 }
203 .into_view()
204 .into_any(),
205 SharePageState::Active => {
206 let Some(i) = info.get() else {
207 return {
208 view! {}.into_any()
209 };
210 };
211 // File shares render through the full-page file view
212 // (opened by the effect above); only folder shares
213 // get a browser.
214 if i.is_file {
215 view! {}
216 .into_view()
217 .into_any()
218 } else {
219 if me.get().is_none() {
220 return view! {
221 <div class="center-screen">
222 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
223 </div>
224 }
225 .into_view()
226 .into_any();
227 }
228 view! {
229 <Browser
230 me=me
231 set_me=set_me
232 loc=loc
233 open_file=open_file
234 set_browser_refresh=set_browser_refresh
235 selected=selected
236 set_selected=set_selected
237 set_dialog=set_dialog
238 dialog=dialog
239 file_view=file_view
240 />
241 }
242 .into_view()
243 .into_any()
244 }
245 }
246 }}
247 {file_content}
248 </main>
249 {overlays}
250 </div>
251 }
252}
253
254/// The password gate of a protected share. The page knows nothing about the
255/// share yet, so it shows the prompt alone.
256#[component]
257fn UnlockForm(token: String, accept: Callback<ShareInfo>) -> impl IntoView {
258 let (password, set_password) = signal(String::new());
259 let (error, set_error) = signal(Option::<String>::None);
260 let (busy, set_busy) = signal(false);
261
262 // A Callback, not a plain closure: both the button and Enter need it,
263 // and a closure would be moved into whichever handler came first.
264 let submit = Callback::new(move |_: ()| {
265 let pw = password.get();
266 if pw.is_empty() || busy.get() {
267 return;
268 }
269 set_busy.set(true);
270 set_error.set(None);
271 let tok = token.clone();
272 spawn_local(async move {
273 match api::unlock_share(&tok, &pw).await {
274 Ok(i) => accept.run(i),
275 Err(e) => {
276 set_error.set(Some(e.to_string()));
277 set_busy.set(false);
278 }
279 }
280 });
281 });
282
283 view! {
284 <div class="center-screen">
285 <div class="card unlock-card">
286 <h2>{i18n::tr(i18n::k::SHARE_LOCKED_TITLE)}</h2>
287 <p class="muted">{i18n::tr(i18n::k::ERR_SHARE_LOCKED)}</p>
288 <input
289 type="password"
290 autocomplete="current-password"
291 autofocus=true
292 value=move || password.get()
293 on:input=move |ev: web_sys::Event| {
294 use wasm_bindgen::JsCast;
295 if let Some(t) = ev
296 .target()
297 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
298 {
299 set_password.set(t.value());
300 set_error.set(None);
301 }
302 }
303 on:keydown=move |ev: web_sys::KeyboardEvent| {
304 if ev.key() == "Enter" {
305 ev.prevent_default();
306 submit.run(());
307 }
308 }
309 />
310 {move || {
311 let Some(e) = error.get() else {
312 return view! {}.into_any();
313 };
314 view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
315 }}
316 <button
317 class="btn btn-primary unlock-btn"
318 disabled=move || busy.get() || password.get().is_empty()
319 on:click=move |_| submit.run(())
320 >
321 {i18n::tr(i18n::k::UNLOCK)}
322 </button>
323 </div>
324 </div>
325 }
326}
327