Skip to main content

pacenotes_server/reports/
report_log.rs

1/// One stored line, stamped with the server's clock since a client's is never trusted.
2#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
3pub struct Record {
4    /// Unix seconds.
5    pub received_at: i64,
6    pub game: String,
7    pub installer: String,
8    pub conflicts: Vec<pacenotes_shared::Conflict>,
9    pub dropped: usize,
10}
11
12/// Where reports go, whose `X-Forwarded-For` to believe, and the limiter.
13pub struct Reports {
14    dir: std::path::PathBuf,
15    trusted: Vec<super::Cidr>,
16    limiter: super::RateLimiter,
17    /// So two reports never interleave in one line.
18    write_lock: tokio::sync::Mutex<()>,
19}
20
21impl Reports {
22    pub fn new(
23        dir: impl Into<std::path::PathBuf>,
24        trusted: Vec<super::Cidr>,
25        limits: super::Limits,
26    ) -> Reports {
27        Reports {
28            dir: dir.into(),
29            trusted,
30            limiter: super::RateLimiter::new(limits),
31            write_lock: tokio::sync::Mutex::new(()),
32        }
33    }
34
35    pub fn client_ip(
36        &self,
37        peer: std::net::IpAddr,
38        headers: &axum::http::HeaderMap,
39    ) -> std::net::IpAddr {
40        super::client_ip(peer, headers, &self.trusted)
41    }
42
43    pub fn check_rate(&self, client: std::net::IpAddr) -> Result<(), u64> {
44        self.limiter.check(client, std::time::Instant::now())
45    }
46
47    /// Appends one JSON line to the day's file.
48    pub async fn append(&self, record: &Record) -> anyhow::Result<std::path::PathBuf> {
49        let mut line = serde_json::to_string(record)?;
50        line.push('\n');
51        let _guard = self.write_lock.lock().await;
52        tokio::fs::create_dir_all(&self.dir)
53            .await
54            .map_err(|e| anyhow::anyhow!("creating {}: {e}", self.dir.display()))?;
55        let path = self.dir.join(super::file_name(record.received_at));
56        let mut file = tokio::fs::OpenOptions::new()
57            .create(true)
58            .append(true)
59            .open(&path)
60            .await
61            .map_err(|e| anyhow::anyhow!("opening {}: {e}", path.display()))?;
62        tokio::io::AsyncWriteExt::write_all(&mut file, line.as_bytes())
63            .await
64            .map_err(|e| anyhow::anyhow!("writing {}: {e}", path.display()))?;
65        // tokio's `write_all` can return before the bytes reach the OS, so the 202 would precede them.
66        tokio::io::AsyncWriteExt::flush(&mut file)
67            .await
68            .map_err(|e| anyhow::anyhow!("flushing {}: {e}", path.display()))?;
69        Ok(path)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    #[tokio::test]
76    async fn records_append_one_line_each_to_the_days_file() {
77        let tmp = tempfile::tempdir().unwrap();
78        let reports = super::Reports::new(
79            tmp.path().join("reports"),
80            vec![],
81            super::super::Limits::default(),
82        );
83        let record = super::Record {
84            received_at: 1_792_886_400,
85            game: "rbr".into(),
86            installer: "0.2.0".into(),
87            conflicts: vec![pacenotes_shared::Conflict {
88                file: "Plugins/Pacenote/PaceNote.ini".into(),
89                packages: vec![
90                    "public/language/polish".into(),
91                    "public/codriver/polish/male/Kamil".into(),
92                ],
93            }],
94            dropped: 0,
95        };
96        let path = reports.append(&record).await.unwrap();
97        reports.append(&record).await.unwrap();
98        assert!(path.ends_with("reports/conflicts-2026-10-25.jsonl"));
99
100        let text = std::fs::read_to_string(&path).unwrap();
101        let lines: Vec<&str> = text.lines().collect();
102        assert_eq!(lines.len(), 2);
103        let back: super::Record = serde_json::from_str(lines[0]).unwrap();
104        assert_eq!(back, record);
105    }
106}