links/
redirector.rs

1//! The main part of links. This module contains code relating to actually
2//! redirecting requests.
3
4use std::fmt::Debug;
5
6use hyper::{
7	header::HeaderValue, http::uri::PathAndQuery, Method, Request, Response, StatusCode, Uri,
8};
9use links_id::Id;
10use links_normalized::Normalized;
11use tokio::time::Instant;
12use tracing::{debug, field::Empty, instrument, trace};
13
14use crate::{
15	config::{Hsts, Redirector as Config},
16	stats::{ExtraStatisticInfo, Statistic},
17	store::Store,
18	util::{csp_hashes, include_html, SERVER_NAME},
19};
20
21/// Redirects the `req`uest to the appropriate target URL (if one is found in
22/// the `store`) or returns a `404 Not Found` response. When redirecting, the
23/// status code is `302 Found` when the method is GET, and `307 Temporary
24/// Redirect` otherwise. Additionally, `stat_info` can be used to pass extra
25/// [`Statistic`]s to be collected in addition to the ones inside of this
26/// function.
27#[instrument(level = "debug", name = "redirect-external", skip_all, fields(http.version = ?req.version(), http.host = %req.uri().host().unwrap_or_else(|| req.headers().get("host").map_or_else(|| "[unknown]", |h| h.to_str().unwrap_or("[unknown]"))), http.path = ?req.uri().path(), http.method = %req.method(), store = %store.backend_name(), time_ns = Empty, link = Empty, id = Empty, vanity = Empty, status_code = Empty))]
28pub async fn redirector<B: Debug + Send + 'static>(
29	req: Request<B>,
30	store: Store,
31	config: Config,
32	stat_info: ExtraStatisticInfo,
33) -> Result<Response<String>, anyhow::Error> {
34	let redirect_start = Instant::now();
35	trace!(?req);
36
37	let path = req.uri().path();
38	let mut res = Response::builder();
39
40	// Set default response headers
41	res = res.header("Referrer-Policy", "unsafe-url");
42	if config.send_server {
43		res = res.header("Server", SERVER_NAME);
44	}
45
46	if config.send_alt_svc {
47		res = res.header("Alt-Svc", "h2=\":443\"; ma=31536000");
48	}
49
50	res = match config.hsts {
51		Hsts::Disable => res,
52		Hsts::Enable(max_age) => {
53			res.header("Strict-Transport-Security", &format!("max-age={max_age}"))
54		}
55		Hsts::IncludeSubDomains(max_age) => res.header(
56			"Strict-Transport-Security",
57			&format!("max-age={max_age}; includeSubDomains"),
58		),
59		Hsts::Preload(max_age) => res.header(
60			"Strict-Transport-Security",
61			&format!("max-age={max_age}; includeSubDomains; preload"),
62		),
63	};
64
65	let id_or_vanity = path.trim_start_matches('/');
66
67	let (id, vanity) = if Id::is_valid(id_or_vanity) {
68		trace!("path is an ID");
69		(Some(Id::try_from(id_or_vanity)?), None)
70	} else {
71		let vanity = Normalized::new(id_or_vanity);
72		trace!("path is a vanity path, normalized to \"{}\"", &vanity);
73		(store.get_vanity(vanity.clone()).await?, Some(vanity))
74	};
75
76	let link = if let Some(id) = id {
77		store.get_redirect(id).await?
78	} else {
79		None
80	};
81
82	let res = if let Some(link) = link.clone() {
83		let link = link.into_string();
84
85		res = res.header("Location", &link);
86		res = res.header("Link-Id", &id.unwrap().to_string());
87
88		if config.send_csp {
89			res = res.header(
90				"Content-Security-Policy",
91				concat!(
92					"default-src 'none'; style-src ",
93					csp_hashes!("redirect", "style"),
94					"; sandbox allow-top-navigation"
95				),
96			);
97		}
98
99		if req.method() == Method::GET {
100			res = res.status(StatusCode::FOUND);
101		} else {
102			res = res.status(StatusCode::TEMPORARY_REDIRECT);
103		}
104
105		res = res.header("Content-Type", "text/html; charset=UTF-8");
106		res.body(
107			include_html!("redirect")
108				.to_string()
109				.replace("{{LINK_URL}}", &link),
110		)?
111	} else {
112		res = res.status(StatusCode::NOT_FOUND);
113		res = res.header("Content-Type", "text/html; charset=UTF-8");
114
115		if config.send_csp {
116			res = res.header(
117				"Content-Security-Policy",
118				concat!(
119					"default-src 'none'; style-src ",
120					csp_hashes!("not-found", "style"),
121					"; sandbox allow-top-navigation"
122				),
123			);
124		}
125
126		res.body(include_html!("not-found").to_string())?
127	};
128
129	let id = id.map(Into::into);
130	let vanity = vanity.map(Into::into);
131
132	let stats = Statistic::get_misc(
133		id.as_ref(),
134		stat_info.clone(),
135		res.status(),
136		config.statistics,
137	)
138	.chain(Statistic::from_req(id.as_ref(), &req, config.statistics))
139	.chain(Statistic::get_misc(
140		vanity.as_ref(),
141		stat_info,
142		res.status(),
143		config.statistics,
144	))
145	.chain(Statistic::from_req(
146		vanity.as_ref(),
147		&req,
148		config.statistics,
149	));
150
151	store.incr_statistics(stats);
152
153	let redirect_time = redirect_start.elapsed();
154
155	trace!(?res);
156	let span = tracing::Span::current();
157	span.record("time_ns", redirect_time.as_nanos());
158	span.record(
159		"link",
160		link.map_or_else(|| "[none]".to_string(), |link| link.to_string()),
161	);
162	span.record(
163		"id",
164		id.map_or_else(|| "[none]".to_string(), |id| id.to_string()),
165	);
166	span.record(
167		"vanity",
168		vanity.map_or_else(|| "[none]".to_string(), |vanity| vanity.to_string()),
169	);
170	span.record("status_code", res.status().as_u16());
171
172	debug!(
173		"External redirect processed in {:.6} seconds",
174		redirect_time.as_secs_f64()
175	);
176
177	Ok(res)
178}
179
180/// Redirects an incoming request to the same host and path, but with the
181/// `https` scheme.
182#[instrument(level = "debug", name = "redirect-https", skip_all, fields(http.version = ?req.version(), http.host = %req.uri().host().unwrap_or_else(|| req.headers().get("host").map_or_else(|| "[unknown]", |h| h.to_str().unwrap_or("[unknown]"))), http.path = ?req.uri().path(), http.method = %req.method(), time_ns = Empty, link = Empty, status_code = Empty))]
183pub async fn https_redirector<B: Debug + Send + 'static>(
184	req: Request<B>,
185	config: Config,
186) -> Result<Response<String>, anyhow::Error> {
187	let redirect_start = Instant::now();
188	trace!(?req);
189
190	// Set default response headers
191	let mut res = Response::builder();
192	res = res.header("Referrer-Policy", "no-referrer");
193	if config.send_server {
194		res = res.header("Server", SERVER_NAME);
195	}
196	if config.send_alt_svc {
197		res = res.header("Alt-Svc", "h2=\":443\"; ma=31536000");
198	}
199
200	let p_and_q = req.uri().path_and_query().map_or("/", PathAndQuery::as_str);
201	let (res, link) = if let Some(Ok(host)) = req.headers().get("host").map(HeaderValue::to_str) {
202		let link = Uri::builder()
203			.scheme("https")
204			.authority(host)
205			.path_and_query(p_and_q)
206			.build()?
207			.to_string();
208
209		res = res.header("Location", &link);
210
211		if req.method() == Method::GET {
212			res = res.status(StatusCode::FOUND);
213		} else {
214			res = res.status(StatusCode::TEMPORARY_REDIRECT);
215		}
216
217		if config.send_csp {
218			res = res.header(
219				"Content-Security-Policy",
220				concat!(
221					"default-src 'none'; style-src ",
222					csp_hashes!("https-redirect", "style"),
223					"; sandbox allow-top-navigation"
224				),
225			);
226		}
227
228		res = res.header("Content-Type", "text/html; charset=UTF-8");
229		(
230			res.body(include_html!("https-redirect").to_string())?,
231			Some(link),
232		)
233	} else {
234		if config.send_csp {
235			res = res.header(
236				"Content-Security-Policy",
237				concat!(
238					"default-src 'none'; style-src ",
239					csp_hashes!("bad-request", "style"),
240					"; sandbox allow-top-navigation"
241				),
242			);
243		}
244
245		res = res.status(StatusCode::BAD_REQUEST);
246		res = res.header("Content-Type", "text/html; charset=UTF-8");
247		(res.body(include_html!("bad-request").to_string())?, None)
248	};
249
250	let redirect_time = redirect_start.elapsed();
251
252	trace!(?res);
253	let span = tracing::Span::current();
254	span.record("time_ns", redirect_time.as_nanos());
255	span.record("link", link.unwrap_or_else(|| "[none]".to_string()));
256	span.record("status_code", res.status().as_u16());
257
258	debug!(
259		"HTTP-to-HTTPS redirect processed in {:.6} seconds",
260		redirect_time.as_secs_f64()
261	);
262
263	Ok(res)
264}