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 default_root_id: None,
58 auth_mode: Default::default(),
59 has_password: false,
60 }),
61 roots: vec![RootInfo {
62 id: i.id,
63 name: i.name.clone(),
64 path: "/".to_string(),
65 mode,
66 }],
67 allow_writable_shares: false,
68 // A share page cannot read the server config, so it asks and lets
69 // the 404 decide. The tile falls back to its icon either way.
70 thumbnails_available: true,
71 public_url: None,
72 }));
73 set_info.set(Some(i));
74 set_state.set(SharePageState::Active);
75 });
76
77 // Resolve the share once per token and scope all file API calls to it.
78 {
79 let tok = token.clone();
80 spawn_local(async move {
81 api::set_share_token(Some(&tok));
82 match api::resolve_share(&tok).await {
83 Ok(i) => accept.run(i),
84 // 401 means password protected, not missing or gone.
85 Err(e) => set_state.set(match e.status() {
86 Some(410) => SharePageState::Expired,
87 Some(401) => SharePageState::Locked,
88 _ => SharePageState::NotFound,
89 }),
90 }
91 });
92 on_cleanup(move || api::set_share_token(None));
93 }
94
95 // The shared file-view plumbing (topbar, close/save/dirty, selection,
96 // dialogs, Escape). Closing a folder share returns to the listing;
97 // closing a file share re-opens the file itself (there is nothing to
98 // close back to, so the close button and Escape stay disabled as
99 // well).
100 let token_for_form = token.clone();
101
102 let ws = FileWorkspace::new(
103 me,
104 loc,
105 move || info.get().and_then(|i| file_view_for_share(&i)),
106 move || state.get() == SharePageState::Active,
107 );
108 let file_view = ws.file_view;
109 let open_file = ws.open_file;
110 let selected = ws.selected;
111 let set_selected = ws.set_selected;
112 let dialog = ws.dialog;
113 let set_dialog = ws.set_dialog;
114 let set_browser_refresh = ws.set_browser_refresh;
115 let topbar_ref = ws.topbar_ref;
116 let topbar_left = ws.topbar_left();
117 let topbar_right = ws.topbar_right();
118 let file_content = ws.file_content();
119 let overlays = ws.overlays();
120
121 // A file share opens straight into its viewer.
122 Effect::new(move |_| {
123 if let Some(i) = info.get()
124 && i.is_file
125 {
126 open_file.set(file_view_for_share(&i));
127 }
128 });
129
130 view! {
131 <div
132 class=move || {
133 if file_view.get().is_some() {
134 "layout share-layout file-mode".to_string()
135 } else {
136 "layout share-layout".to_string()
137 }
138 }
139 >
140 <header class="topbar" node_ref=topbar_ref>
141 <div class="topbar-left">
142 {topbar_left}
143 </div>
144 <div class="topbar-right">
145 {topbar_right}
146 </div>
147 </header>
148 <main class="content" class:file-open=move || file_view.get().is_some()>
149 {move || match state.get() {
150 SharePageState::Loading => view! {
151 <div class="center-screen">
152 <p class="muted">{i18n::tr(i18n::k::LOADING_SHARE)}</p>
153 </div>
154 }
155 .into_any(),
156 SharePageState::NotFound => view! {
157 <div class="center-screen">
158 <h2>{i18n::tr(i18n::k::SHARE_NOT_FOUND)}</h2>
159 <p class="muted">{i18n::tr(i18n::k::SHARE_NOT_FOUND_MSG)}</p>
160 </div>
161 }
162 .into_any(),
163 SharePageState::Expired => view! {
164 <div class="center-screen">
165 <h2>{i18n::tr(i18n::k::SHARE_EXPIRED)}</h2>
166 <p class="muted">{i18n::tr(i18n::k::SHARE_EXPIRED_MSG)}</p>
167 </div>
168 }
169 .into_any(),
170 SharePageState::Locked => view! {
171 <UnlockForm token=token_for_form.clone() accept=accept/>
172 }
173 .into_any(),
174 SharePageState::Active => {
175 // File shares render through the full-page file view
176 // (opened by the effect above); only folder shares
177 // get a browser.
178 if info.get().is_none_or(|i| i.is_file) {
179 ().into_any()
180 } else {
181 if me.get().is_none() {
182 return view! {
183 <div class="center-screen">
184 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
185 </div>
186 }
187 .into_any();
188 }
189 view! {
190 <Browser
191 me=me
192 set_me=set_me
193 loc=loc
194 open_file=open_file
195 set_browser_refresh=set_browser_refresh
196 selected=selected
197 set_selected=set_selected
198 set_dialog=set_dialog
199 dialog=dialog
200 file_view=file_view
201 />
202 }
203 .into_any()
204 }
205 }
206 }}
207 {file_content}
208 </main>
209 {overlays}
210 </div>
211 }
212}
213
214/// The password gate of a protected share. The page knows nothing about the
215/// share yet, so it shows the prompt alone.
216#[component]
217fn UnlockForm(token: String, accept: Callback<ShareInfo>) -> impl IntoView {
218 let (password, set_password) = signal(String::new());
219 let (error, set_error) = signal(Option::<String>::None);
220 let (busy, set_busy) = signal(false);
221
222 // A Callback, not a plain closure: both the button and Enter need it,
223 // and a closure would be moved into whichever handler came first.
224 let submit = Callback::new(move |_: ()| {
225 let pw = password.get();
226 if pw.is_empty() || busy.get() {
227 return;
228 }
229 set_busy.set(true);
230 set_error.set(None);
231 let tok = token.clone();
232 spawn_local(async move {
233 match api::unlock_share(&tok, &pw).await {
234 Ok(i) => accept.run(i),
235 Err(e) => {
236 set_error.set(Some(e.to_string()));
237 set_busy.set(false);
238 }
239 }
240 });
241 });
242
243 view! {
244 <div class="center-screen">
245 <div class="card unlock-card">
246 <h2>{i18n::tr(i18n::k::SHARE_LOCKED_TITLE)}</h2>
247 <p class="muted">{i18n::tr(i18n::k::ERR_SHARE_LOCKED)}</p>
248 <input
249 type="password"
250 autocomplete="current-password"
251 autofocus=true
252 value=move || password.get()
253 on:input=move |ev| {
254 set_password.set(event_target_value(&ev));
255 set_error.set(None);
256 }
257 on:keydown=move |ev: web_sys::KeyboardEvent| {
258 if ev.key() == "Enter" {
259 ev.prevent_default();
260 submit.run(());
261 }
262 }
263 />
264 {move || error.get().map(|e| view! { <p class="dialog-error">{e}</p> })}
265 <button
266 class="btn btn-primary unlock-btn"
267 disabled=move || busy.get() || password.get().is_empty()
268 on:click=move |_| submit.run(())
269 >
270 {i18n::tr(i18n::k::UNLOCK)}
271 </button>
272 </div>
273 </div>
274 }
275}
276