router.rs
⎇
Raw
1//! Tiny hash-based router.
2//!
3//! - `#/` — root picker / single-root browser
4//! - `#/files/{root_id}/{segment/...}` — a user's folder
5//! - `#/share/{token}/{segment/...}` — a public share (no login)
6//! - `#/shares`, `#/users`, `#/settings`, `#/uploads` — the signed-in
7//! shell's manage views
8//!
9//! The URL hash is the single source of truth; the `Location` signal in the
10//! app mirrors it.
11
12/// Which section of the signed-in shell a location points at. `Files` is the
13/// browser; the rest are the manage views, each with its own URL.
14#[derive(Clone, Copy, PartialEq, Eq, Debug)]
15pub enum Section {
16 Files,
17 /// The search view (`#/search`); query params live in the hash query.
18 Search,
19 Shares,
20 Users,
21 Settings,
22 /// This session's upload jobs (`#/uploads`).
23 Uploads,
24}
25
26impl Section {
27 /// The URL segment for this section, or `None` for `Files` (which has no
28 /// segment of its own — the browser is the default view).
29 const fn segment(self) -> Option<&'static str> {
30 match self {
31 Section::Files => None,
32 Section::Search => Some("search"),
33 Section::Shares => Some("shares"),
34 Section::Users => Some("users"),
35 Section::Settings => Some("settings"),
36 Section::Uploads => Some("uploads"),
37 }
38 }
39
40 /// The inverse of [`segment`]: the section a URL segment names. `files`
41 /// has no segment, so it is never returned here.
42 fn from_segment(seg: &str) -> Option<Self> {
43 [
44 Section::Search,
45 Section::Shares,
46 Section::Users,
47 Section::Settings,
48 Section::Uploads,
49 ]
50 .into_iter()
51 .find(|s| s.segment() == Some(seg))
52 }
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Location {
57 /// None = the top-level "folders" view (root picker).
58 pub root_id: Option<i64>,
59 /// Path segments relative to the current root (or share target).
60 pub path: Vec<String>,
61 /// Present when the location is a public share (`#/share/{token}/...`).
62 pub share_token: Option<String>,
63 /// Which shell section the location points at.
64 pub section: Section,
65}
66
67impl Location {
68 pub const fn root() -> Self {
69 Self {
70 root_id: None,
71 path: Vec::new(),
72 share_token: None,
73 section: Section::Files,
74 }
75 }
76}
77
78/// Percent-encode one path segment for the URL hash. Without this a name
79/// containing `#`, `%` or `/` would break the round trip.
80fn encode_segment(seg: &str) -> String {
81 js_sys::encode_uri_component(seg).into()
82}
83
84fn decode_segment(seg: &str) -> String {
85 // A hand-typed hash can hold invalid escapes; keep it verbatim then.
86 js_sys::decode_uri_component(seg)
87 .map(String::from)
88 .unwrap_or_else(|_| seg.to_string())
89}
90
91pub fn parse_location() -> Location {
92 let hash = web_sys::window()
93 .and_then(|w| w.location().hash().ok())
94 .unwrap_or_default();
95 let h = hash.trim_start_matches('#');
96 let h = h.trim_start_matches('/');
97 // Hash query (e.g. `#/search?q=…`) is not part of the path; the search
98 // view parses it itself.
99 let h = h.split_once('?').map(|(p, _)| p).unwrap_or(h);
100 let mut parts = h.split('/').filter(|s| !s.is_empty());
101 match parts.next() {
102 Some("files") => match parts.next().and_then(|s| s.parse().ok()) {
103 Some(root_id) => Location {
104 root_id: Some(root_id),
105 path: parts.map(decode_segment).collect(),
106 share_token: None,
107 section: Section::Files,
108 },
109 None => Location::root(),
110 },
111 // `#/share/{token}/...` — public share page.
112 Some("share") => match parts.next() {
113 Some(token) => Location {
114 root_id: None,
115 path: parts.map(decode_segment).collect(),
116 share_token: Some(token.to_string()),
117 section: Section::Files,
118 },
119 // `#/share` without a token is not a share; treat it as home.
120 None => Location::root(),
121 },
122 // `#/search`, `#/shares`, `#/users`, `#/settings` — the manage views.
123 // Each is just a section; none carries a root or a path.
124 seg => match seg.and_then(Section::from_segment) {
125 Some(section) => Location {
126 section,
127 ..Location::root()
128 },
129 None => Location::root(),
130 },
131 }
132}
133
134pub fn location_to_hash(loc: &Location) -> String {
135 if let Some(token) = &loc.share_token {
136 let mut s = format!("#/share/{token}");
137 for seg in &loc.path {
138 s.push('/');
139 s.push_str(&encode_segment(seg));
140 }
141 return s;
142 }
143 if let Some(segment) = loc.section.segment() {
144 return format!("#/{segment}");
145 }
146 match loc.root_id {
147 None => "#/".to_string(),
148 Some(id) => {
149 let mut s = format!("#/files/{id}");
150 for seg in &loc.path {
151 s.push('/');
152 s.push_str(&encode_segment(seg));
153 }
154 s
155 }
156 }
157}
158
159/// Change the app location (updates the hash; the app listens for hashchange).
160pub fn navigate(loc: &Location) {
161 if let Some(w) = web_sys::window() {
162 let _ = w.location().set_hash(&location_to_hash(loc));
163 }
164}
165
166thread_local! {
167 /// The server's `--public-url`, set from every `/me` response. `None`
168 /// means "use the address the browser is connected to".
169 static PUBLIC_URL: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
170}
171
172pub fn set_public_url(url: Option<String>) {
173 PUBLIC_URL.with(|p| *p.borrow_mut() = url);
174}
175
176fn public_url() -> Option<String> {
177 PUBLIC_URL.with(|p| p.borrow().clone())
178}
179
180/// Build the WebDAV mount URL for a share token.
181///
182/// Origin-relative, not page-relative: the mount is a server route, so it does
183/// not live under whatever path the app itself was loaded from. Only folder
184/// shares have one; a file share has no collection to mount.
185pub fn dav_share_url(token: &str) -> String {
186 let origin = public_url().unwrap_or_else(|| {
187 web_sys::window()
188 .and_then(|w| w.location().origin().ok())
189 .unwrap_or_default()
190 });
191 format!("{origin}{}/{token}", api_types::DAV_SHARE)
192}
193
194/// Build the share link for a token: under the public URL when the server
195/// has one, else relative to the current page.
196pub fn share_url(token: &str) -> String {
197 if let Some(p) = public_url() {
198 return format!("{p}/#/share/{token}");
199 }
200 let base = web_sys::window()
201 .and_then(|w| w.location().href().ok())
202 .unwrap_or_default();
203 // Cut off any existing hash, then append the share hash.
204 let cut = base.find('#').unwrap_or(base.len());
205 format!("{}#/share/{token}", &base[..cut])
206}
207