pacenotes_server/reports/
report_log.rs1#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
3pub struct Record {
4 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
12pub struct Reports {
14 dir: std::path::PathBuf,
15 trusted: Vec<super::Cidr>,
16 limiter: super::RateLimiter,
17 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 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::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}