Skip to main content

sofie_http/
keyed.rs

1use std::net::IpAddr;
2
3use http::StatusCode;
4use sofie_auth::digest;
5use sofie_core::Result;
6use sofie_db::{Context, Db, Keyed, KeyedWork, Reply, Run, Work};
7
8use crate::admission::{Admission, Visit};
9use crate::answer::{self, Response};
10use crate::pipeline::RequestBody;
11use crate::throttle::Requester;
12use crate::{Area, RequestContext};
13
14const IDEMPOTENCY_KEY: &str = "idempotency-key";
15/// What vendors accept as their own idempotency key, and enough for a UUID.
16const 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    /// Keyed within the route and the principal, so no one replays another's answer.
32    pub(crate) fn work(
33        &self,
34        visit: Visit<'_>,
35        pattern: &str,
36        received: &RequestBody,
37    ) -> Result<Option<Work>, Box<Response>> {
38        #[cfg(feature = "webhooks")]
39        if let Some(verify) = &self.rules.verify {
40            return crate::webhook::delivered_work(
41                verify.as_ref(),
42                visit,
43                pattern,
44                &received.fingerprint,
45            )
46            .map(Some);
47        }
48        if !self.rules.idempotent {
49            return Ok(None);
50        }
51        requested_work(visit, pattern, received).map(Some)
52    }
53}
54
55fn requested_work(
56    visit: Visit<'_>,
57    pattern: &str,
58    received: &RequestBody,
59) -> Result<Work, Box<Response>> {
60    let request = visit.asked.request;
61    let key = request
62        .headers
63        .get(IDEMPOTENCY_KEY)
64        .and_then(|key| key.to_str().ok())
65        .or_else(|| {
66            received
67                .form_key
68                .as_deref()
69                .filter(|key| key.bytes().all(|byte| byte.is_ascii_graphic()))
70        })
71        .filter(|key| (1..=MAX_KEY_LENGTH).contains(&key.len()))
72        .ok_or_else(|| {
73            Box::new(answer::refusal(
74                StatusCode::BAD_REQUEST,
75                KEY_REQUIRED,
76                visit.asked.caller,
77            ))
78        })?;
79    let key = digest(&[
80        b"request",
81        request.method.as_str().as_bytes(),
82        pattern.as_bytes(),
83        &requester_bytes(visit),
84        key.as_bytes(),
85    ]);
86    // The path, not the pattern: one key sent for another record is another request.
87    let path = request.uri.path();
88    let query = request.uri.query().unwrap_or_default();
89    Ok(Work::new(
90        key,
91        digest(&[path.as_bytes(), query.as_bytes(), &received.fingerprint]),
92    ))
93}
94
95/// An anonymous caller by its address, so it never replays another anonymous caller's answer.
96fn requester_bytes(visit: Visit<'_>) -> Vec<u8> {
97    match Requester::of(visit.principal, visit.asked.client) {
98        Requester::Principal(principal) => [b"principal:", &principal.as_bytes()[..]].concat(),
99        Requester::Address(IpAddr::V4(address)) => [b"address:", &address.octets()[..]].concat(),
100        Requester::Address(IpAddr::V6(address)) => [b"address:", &address.octets()[..]].concat(),
101    }
102}
103
104impl<A: Area> KeyedWork for RequestContext<A> {
105    fn work(&self) -> Option<&Work> {
106        self.matched.work.as_ref()
107    }
108}
109
110/// A key claimed by a concurrent request gets its answer instead of writing twice.
111pub(crate) async fn answer_once<A: Area>(
112    run: &Run<RequestContext<A>>,
113    mut context: RequestContext<A>,
114    work: &Work,
115) -> Result<Reply> {
116    let db = context.db().clone();
117    let keyed = Keyed::new(&db, work);
118    if let Some(reply) = keyed.replay().await? {
119        return Ok(reply);
120    }
121    let mut outcome = run(&mut context).await?;
122    context.matched.deadline.lift();
123    if let Some(reply) = keyed.commit(&mut *outcome, context.reach()).await? {
124        return Ok(reply);
125    }
126    context.session().committed();
127    #[cfg(feature = "uploads")]
128    crate::uploads::committed();
129    let reply = outcome.into_reply()?;
130    keyed.record(&reply).await?;
131    Ok(reply)
132}
133
134#[cfg(test)]
135mod tests {
136    use sofie_core::Timestamp;
137
138    use super::*;
139    use crate::caller::Caller;
140    use crate::deadline::Deadline;
141
142    fn work(path: &str, client: &str, principal: Option<sofie_core::Uuid>) -> Work {
143        let (request, ()) = http::Request::post(path)
144            .header(IDEMPOTENCY_KEY, "same-key")
145            .body(())
146            .unwrap()
147            .into_parts();
148        let deadline = Deadline::default();
149        let visit = Visit {
150            asked: crate::pipeline::Asked {
151                request: &request,
152                caller: Caller::Json,
153                client: client.parse().unwrap(),
154                deadline: &deadline,
155            },
156            principal,
157            now: Timestamp::UNIX_EPOCH,
158            #[cfg(feature = "signed-links")]
159            keys: &sofie_auth::SigningKeys::generate().unwrap(),
160        };
161        let received = RequestBody::plain(bytes::Bytes::new(), &request.headers);
162        requested_work(visit, "/orders/{order}/cancel", &received)
163            .unwrap_or_else(|_| panic!("keyed"))
164    }
165
166    fn keyed(client: &str, principal: Option<sofie_core::Uuid>) -> String {
167        work("/orders/1/cancel", client, principal)
168            .key()
169            .as_str()
170            .to_owned()
171    }
172
173    #[test]
174    fn one_key_sent_for_another_record_is_another_request() {
175        let principal = Some(sofie_core::Uuid::now_v7().unwrap());
176
177        let first = work("/orders/1/cancel", "192.0.2.1", principal);
178        let second = work("/orders/2/cancel", "192.0.2.1", principal);
179
180        assert_eq!(first.key().as_str(), second.key().as_str());
181        assert_ne!(format!("{first:?}"), format!("{second:?}"));
182    }
183
184    #[test]
185    fn anonymous_callers_never_share_a_key_and_a_principal_keeps_its_own() {
186        let principal = Some(sofie_core::Uuid::now_v7().unwrap());
187
188        assert_ne!(keyed("192.0.2.1", None), keyed("192.0.2.2", None));
189        assert_eq!(keyed("192.0.2.1", principal), keyed("192.0.2.2", principal));
190    }
191}