1use std::net::IpAddr;
2
3use bytes::Bytes;
4use http::StatusCode;
5use sofie_auth::digest;
6use sofie_core::Result;
7use sofie_db::{Context, Db, Keyed, KeyedWork, Reply, Run, Work};
8
9use crate::admission::{Admission, Visit};
10use crate::answer::{self, Response};
11use crate::throttle::Requester;
12use crate::{Area, RequestContext};
13
14const IDEMPOTENCY_KEY: &str = "idempotency-key";
15const MAX_KEY_LENGTH: usize = 255;
17const SWEEP_EVERY: std::time::Duration = std::time::Duration::from_hours(1);
18const KEY_REQUIRED: &str =
19 "This request needs an Idempotency-Key header of 1 to 255 visible characters.";
20
21pub(crate) async fn sweep_expired_keys(db: Db) {
22 loop {
23 tokio::time::sleep(SWEEP_EVERY).await;
24 if let Err(error) = sofie_db::sweep_expired_keys(&db).await {
25 tracing::warn!(error.kind = "idempotency", error = %error, "sweeping expired idempotency keys failed");
26 }
27 }
28}
29
30impl Admission {
31 pub(crate) fn work(
33 &self,
34 visit: Visit<'_>,
35 pattern: &str,
36 body: &Bytes,
37 ) -> Result<Option<Work>, Box<Response>> {
38 #[cfg(feature = "webhooks")]
39 if let Some(verify) = &self.rules.verify {
40 return crate::webhook::delivered_work(verify.as_ref(), visit, pattern, body).map(Some);
41 }
42 if !self.rules.idempotent {
43 return Ok(None);
44 }
45 requested_work(visit, pattern, body).map(Some)
46 }
47}
48
49fn requested_work(visit: Visit<'_>, pattern: &str, body: &Bytes) -> Result<Work, Box<Response>> {
50 let request = visit.asked.request;
51 let key = request
52 .headers
53 .get(IDEMPOTENCY_KEY)
54 .and_then(|key| key.to_str().ok())
55 .filter(|key| (1..=MAX_KEY_LENGTH).contains(&key.len()))
56 .ok_or_else(|| {
57 Box::new(answer::refusal(
58 StatusCode::BAD_REQUEST,
59 KEY_REQUIRED,
60 visit.asked.caller,
61 ))
62 })?;
63 let key = digest(&[
64 b"request",
65 request.method.as_str().as_bytes(),
66 pattern.as_bytes(),
67 &requester_bytes(visit),
68 key.as_bytes(),
69 ]);
70 let path = request.uri.path();
72 let query = request.uri.query().unwrap_or_default();
73 Ok(Work::new(
74 key,
75 digest(&[path.as_bytes(), query.as_bytes(), body]),
76 ))
77}
78
79fn requester_bytes(visit: Visit<'_>) -> Vec<u8> {
81 match Requester::of(visit.principal, visit.asked.client) {
82 Requester::Principal(principal) => [b"principal:", &principal.as_bytes()[..]].concat(),
83 Requester::Address(IpAddr::V4(address)) => [b"address:", &address.octets()[..]].concat(),
84 Requester::Address(IpAddr::V6(address)) => [b"address:", &address.octets()[..]].concat(),
85 }
86}
87
88impl<A: Area> KeyedWork for RequestContext<A> {
89 fn work(&self) -> Option<&Work> {
90 self.matched.work.as_ref()
91 }
92}
93
94pub(crate) async fn answer_once<A: Area>(
96 run: &Run<RequestContext<A>>,
97 mut context: RequestContext<A>,
98 work: &Work,
99) -> Result<Reply> {
100 let db = context.db().clone();
101 let keyed = Keyed::new(&db, work);
102 if let Some(reply) = keyed.replay().await? {
103 return Ok(reply);
104 }
105 let mut outcome = run(&mut context).await?;
106 context.matched.deadline.lift();
107 if let Some(reply) = keyed.commit(&mut *outcome, context.reach()).await? {
108 return Ok(reply);
109 }
110 context.session().committed();
111 #[cfg(feature = "uploads")]
112 crate::uploads::committed();
113 let reply = outcome.into_reply()?;
114 keyed.record(&reply).await?;
115 Ok(reply)
116}
117
118#[cfg(test)]
119mod tests {
120 use sofie_core::Timestamp;
121
122 use super::*;
123 use crate::caller::Caller;
124 use crate::deadline::Deadline;
125
126 fn work(path: &str, client: &str, principal: Option<sofie_core::Uuid>) -> Work {
127 let (request, ()) = http::Request::post(path)
128 .header(IDEMPOTENCY_KEY, "same-key")
129 .body(())
130 .unwrap()
131 .into_parts();
132 let deadline = Deadline::default();
133 let visit = Visit {
134 asked: crate::pipeline::Asked {
135 request: &request,
136 caller: Caller::Json,
137 client: client.parse().unwrap(),
138 deadline: &deadline,
139 },
140 principal,
141 now: Timestamp::UNIX_EPOCH,
142 #[cfg(feature = "signed-links")]
143 keys: &sofie_auth::SigningKeys::generate().unwrap(),
144 };
145 let body = Bytes::new();
146 requested_work(visit, "/orders/{order}/cancel", &body).unwrap_or_else(|_| panic!("keyed"))
147 }
148
149 fn keyed(client: &str, principal: Option<sofie_core::Uuid>) -> String {
150 work("/orders/1/cancel", client, principal)
151 .key()
152 .as_str()
153 .to_owned()
154 }
155
156 #[test]
157 fn one_key_sent_for_another_record_is_another_request() {
158 let principal = Some(sofie_core::Uuid::now_v7().unwrap());
159
160 let first = work("/orders/1/cancel", "192.0.2.1", principal);
161 let second = work("/orders/2/cancel", "192.0.2.1", principal);
162
163 assert_eq!(first.key().as_str(), second.key().as_str());
164 assert_ne!(format!("{first:?}"), format!("{second:?}"));
165 }
166
167 #[test]
168 fn anonymous_callers_never_share_a_key_and_a_principal_keeps_its_own() {
169 let principal = Some(sofie_core::Uuid::now_v7().unwrap());
170
171 assert_ne!(keyed("192.0.2.1", None), keyed("192.0.2.2", None));
172 assert_eq!(keyed("192.0.2.1", principal), keyed("192.0.2.2", principal));
173 }
174}