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