1use http::request::Parts;
2use http::{Method, StatusCode};
3use sofie_core::Result;
4
5use crate::answer::{self, Redirect, Response};
6use crate::caller::Caller;
7use crate::router::{Lookup, Router};
8use crate::routes::Audience;
9use crate::session::RequestSession;
10use crate::urlencoded::{self, percent_encode};
11use crate::{Access, Area, RequestContext};
12use sofie_db::Resolve;
13
14const SIGN_IN_FIRST: &str = "Sign in to continue.";
15
16pub(crate) fn unauthenticated(audience: Audience, request: &Parts, caller: Caller<'_>) -> Response {
17 let location = sign_in_location(audience.sign_in, request);
18 let redirect = match caller {
19 Caller::Json => return answer::refusal(StatusCode::UNAUTHORIZED, SIGN_IN_FIRST, caller),
20 #[cfg(feature = "clients")]
21 Caller::Screen => return answer::refusal(StatusCode::UNAUTHORIZED, SIGN_IN_FIRST, caller),
22 Caller::Htmx { .. } => {
23 answer::redirect(Redirect::Navigate, &location).map(|mut response| {
24 *response.status_mut() = StatusCode::UNAUTHORIZED;
25 response
26 })
27 }
28 Caller::FormPost | Caller::Page | Caller::Browser => {
29 answer::redirect(Redirect::SeeOther, &location)
30 }
31 };
32 answer::or_failure(redirect, caller)
33}
34
35fn sign_in_location(sign_in: &str, request: &Parts) -> String {
37 let wanted = request
38 .uri
39 .path_and_query()
40 .map(http::uri::PathAndQuery::as_str)
41 .filter(|path| request.method == Method::GET && is_local_path(path));
42 with_next(sign_in, wanted)
43}
44
45fn with_next(action: &str, next: Option<&str>) -> String {
46 match next {
47 Some(path) => format!("{action}?next={}", percent_encode(path)),
48 None => action.to_owned(),
49 }
50}
51
52pub(crate) fn is_local_path(path: &str) -> bool {
54 path.starts_with('/')
55 && !path.starts_with("//")
56 && !path.contains('\\')
57 && !path.contains(char::is_control)
58}
59
60pub struct Next {
61 path: Option<String>,
62}
63
64impl Next {
65 #[must_use]
66 pub fn carried_by(&self, action: &str) -> String {
67 with_next(action, self.path.as_deref())
68 }
69
70 #[must_use]
72 pub fn path(&self) -> Option<&str> {
73 self.path.as_deref()
74 }
75}
76
77impl<A: Area, M> Resolve<RequestContext<A, M>> for Next {
78 fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
79 std::future::ready(Ok(Self {
80 path: next_path(context.query()),
81 }))
82 }
83}
84
85fn next_path(query: Option<&str>) -> Option<String> {
87 let raw = query?
88 .split('&')
89 .find_map(|pair| pair.strip_prefix("next="))?;
90 let path = urlencoded::decode(raw.as_bytes()).ok()?;
91 let again = urlencoded::decode(path.as_bytes()).ok()?;
92 (path.is_ascii() && is_local_path(&path) && is_local_path(&again)).then_some(path)
93}
94
95pub(crate) fn signed_in_destination(
97 request: &Parts,
98 session: &RequestSession,
99 router: &Router,
100) -> Option<String> {
101 let area = session.signed_in_area()?;
102 let path = next_path(request.uri.query())?;
103 let page = path.split('?').next().unwrap_or_default();
104 let Lookup::Found(target, _) = router.find(&Method::GET, page) else {
105 return None;
106 };
107 let audience = target.audience;
108 let opens = audience.access == Access::Public || audience.name == area;
109 opens.then_some(path)
110}
111
112#[cfg(test)]
113mod tests {
114 use http::Request;
115
116 use super::*;
117
118 fn location(method: Method, uri: &str) -> String {
119 let (parts, ()) = Request::builder()
120 .method(method)
121 .uri(uri)
122 .body(())
123 .unwrap()
124 .into_parts();
125 sign_in_location("/sign-in", &parts)
126 }
127
128 #[test]
129 fn a_page_is_named_as_the_next_path() {
130 assert_eq!(
131 location(Method::GET, "/account/orders?page=2"),
132 "/sign-in?next=/account/orders%3Fpage%3D2"
133 );
134 }
135
136 #[test]
137 fn a_next_path_is_decoded_and_kept_only_on_this_site() {
138 assert_eq!(
139 next_path(Some("a=1&next=/account/orders%3Fpage%3D2")),
140 Some("/account/orders?page=2".to_owned())
141 );
142 for refused in [
143 "next=//evil.test",
144 "next=/\\evil.test",
145 "next=https://evil.test",
146 "next=javascript:alert(1)",
147 "next=%2F%2Fevil.test",
148 "next=%2f%5Cevil.test",
149 "next=/%2F/evil.test",
150 "next=/%5Cevil.test",
151 "next=%252F%252Fevil.test",
152 "next=/%2F%252Fevil.test",
153 "next=%09//evil.test",
154 "next=/%09/evil.test",
155 "next=/%E2%80%8B",
156 "next=%",
157 ] {
158 assert_eq!(next_path(Some(refused)), None, "{refused}");
159 }
160 assert_eq!(next_path(None), None);
161 }
162
163 #[test]
164 fn no_next_path_leaves_the_site_or_repeats_a_write() {
165 assert_eq!(location(Method::GET, "//evil.test/x"), "/sign-in");
166 assert_eq!(
167 location(Method::GET, "https://evil.test/x"),
168 "/sign-in?next=/x"
169 );
170 assert_eq!(location(Method::POST, "/account/orders"), "/sign-in");
171 assert!(!is_local_path("/\\evil.test"));
172 assert!(!is_local_path("evil.test"));
173 }
174}