Skip to main content

rustlavel_audit/
store.rs

1//! Reading and writing the trail.
2
3use crate::entry::{Builder, Entry};
4use crate::tables::TABLE;
5use rustlavel_core::{Json, Result};
6use rustlavel_db::{Database, Value};
7
8/// The audit trail. Cheap to clone; every clone writes to the same table.
9/// No `Debug`: the only field is a database handle, and printing a connection
10/// pool in a log is how a connection string ends up in a bug report.
11#[derive(Clone)]
12pub struct Trail {
13    db: Database,
14}
15
16/// What to narrow a listing by. Every field is optional and they combine with
17/// AND, which is what the filter bar on an audit page does.
18#[derive(Debug, Clone, Default)]
19pub struct Filter {
20    pub user_id: Option<i64>,
21    pub event: Option<String>,
22    pub model_type: Option<String>,
23    /// Inclusive, as `YYYY-MM-DD`.
24    pub from: Option<String>,
25    /// Inclusive: the whole of that day, not the midnight at the start of it.
26    pub to: Option<String>,
27    pub ip_address: Option<String>,
28    /// Matched against the description, the event and the actor's name.
29    pub search: Option<String>,
30}
31
32/// One page of entries, and enough to draw a pager.
33#[derive(Debug, Clone)]
34pub struct Page {
35    pub entries: Vec<Entry>,
36    pub total: i64,
37    pub page: i64,
38    pub per_page: i64,
39}
40
41impl Page {
42    pub fn pages(&self) -> i64 {
43        if self.per_page <= 0 { 1 } else { ((self.total + self.per_page - 1) / self.per_page).max(1) }
44    }
45
46    /// The 1-based index of the first row on this page, for "showing 1 to 50".
47    pub fn first(&self) -> i64 {
48        if self.total == 0 { 0 } else { (self.page - 1) * self.per_page + 1 }
49    }
50
51    pub fn last(&self) -> i64 {
52        ((self.page - 1) * self.per_page + self.entries.len() as i64).max(0)
53    }
54}
55
56impl Trail {
57    pub fn new(db: Database) -> Trail {
58        Trail { db }
59    }
60
61    pub fn database(&self) -> &Database {
62        &self.db
63    }
64
65    /// Start an entry. Nothing is written until [`Builder::save`].
66    pub fn event(&self, event: impl Into<String>) -> Builder {
67        Builder { trail: self.clone(), entry: Entry::new(event) }
68    }
69
70    /// Write one, returning its id.
71    pub async fn write(&self, entry: Entry) -> Result<i64> {
72        let properties = match &entry.properties {
73            Json::Null => None,
74            value => Some(value.to_string()),
75        };
76        let now = now();
77
78        self.db
79            .table(TABLE)
80            .insert(
81                &self.db,
82                &[
83                    ("user_id", entry.user_id.map_or(Value::Null, Value::from)),
84                    ("user_name", optional(entry.user_name)),
85                    ("event", Value::from(entry.event)),
86                    ("model_type", optional(entry.model_type)),
87                    ("model_id", optional(entry.model_id)),
88                    ("description", optional(entry.description)),
89                    ("properties", optional(properties)),
90                    ("ip_address", optional(entry.ip_address)),
91                    // Long agent strings are a nuisance in a table and tell
92                    // nobody anything past the first line's worth.
93                    ("user_agent", optional(entry.user_agent.map(|a| a.chars().take(255).collect::<String>()))),
94                    ("created_at", Value::from(now.clone())),
95                    ("updated_at", Value::from(now)),
96                ],
97            )
98            .await
99    }
100
101    /// One page, newest first.
102    pub async fn page(&self, filter: &Filter, page: i64, per_page: i64) -> Result<Page> {
103        let page = page.max(1);
104        let per_page = per_page.clamp(1, 500);
105
106        let total = self.query(filter).count(&self.db).await?;
107        let rows = self
108            .query(filter)
109            .latest("id")
110            .limit(per_page)
111            .offset((page - 1) * per_page)
112            .get(&self.db)
113            .await?;
114
115        Ok(Page { entries: rows.iter().map(read).collect(), total, page, per_page })
116    }
117
118    /// Every matching entry, newest first, up to `limit`. For an export.
119    pub async fn all(&self, filter: &Filter, limit: i64) -> Result<Vec<Entry>> {
120        let rows =
121            self.query(filter).latest("id").limit(limit.clamp(1, 100_000)).get(&self.db).await?;
122        Ok(rows.iter().map(read).collect())
123    }
124
125    pub async fn find(&self, id: i64) -> Result<Option<Entry>> {
126        let rows = self.db.table(TABLE).filter("id", id).limit(1).get(&self.db).await?;
127        Ok(rows.first().map(read))
128    }
129
130    /// Every distinct event name that has been recorded, for a filter's
131    /// dropdown. Read from the trail rather than declared, so an application
132    /// that records something new does not also have to register it.
133    pub async fn events(&self) -> Result<Vec<String>> {
134        let rows = self.db.table(TABLE).group_by(&["event"]).select(&["event"]).get(&self.db).await?;
135        let mut names: Vec<String> =
136            rows.iter().filter_map(|row| row.get::<String>("event").ok()).collect();
137        names.sort();
138        Ok(names)
139    }
140
141    /// The same for the model types.
142    pub async fn model_types(&self) -> Result<Vec<String>> {
143        let rows = self
144            .db
145            .table(TABLE)
146            .filter_not_null("model_type")
147            .group_by(&["model_type"])
148            .select(&["model_type"])
149            .get(&self.db)
150            .await?;
151        let mut names: Vec<String> =
152            rows.iter().filter_map(|row| row.get::<String>("model_type").ok()).filter(|n| !n.is_empty()).collect();
153        names.sort();
154        Ok(names)
155    }
156
157    pub async fn count(&self, filter: &Filter) -> Result<i64> {
158        self.query(filter).count(&self.db).await
159    }
160
161    /// How many entries were written on or after `since`.
162    pub async fn count_since(&self, since: &str) -> Result<i64> {
163        self.db.table(TABLE).filter_op("created_at", ">=", since).count(&self.db).await
164    }
165
166    /// How many different accounts appear in the trail.
167    pub async fn distinct_users(&self) -> Result<i64> {
168        let rows = self
169            .db
170            .table(TABLE)
171            .filter_not_null("user_id")
172            .group_by(&["user_id"])
173            .select(&["user_id"])
174            .get(&self.db)
175            .await?;
176        Ok(rows.len() as i64)
177    }
178
179    /// When the last entry was written, if there is one.
180    pub async fn latest_at(&self) -> Result<Option<String>> {
181        let rows = self.db.table(TABLE).latest("id").limit(1).get(&self.db).await?;
182        Ok(rows.first().and_then(|row| row.get::<String>("created_at").ok()))
183    }
184
185    /// Delete everything older than a date. A trail nobody prunes becomes the
186    /// largest table in the database, and a retention rule is a decision the
187    /// application makes rather than one this package makes for it.
188    pub async fn prune_before(&self, cutoff: &str) -> Result<u64> {
189        self.db.table(TABLE).filter_op("created_at", "<", cutoff).delete(&self.db).await
190    }
191
192    fn query(&self, filter: &Filter) -> rustlavel_db::QueryBuilder {
193        let mut query = self.db.table(TABLE);
194
195        if let Some(user_id) = filter.user_id {
196            query = query.filter("user_id", user_id);
197        }
198        if let Some(event) = filter.event.as_deref().filter(|e| !e.is_empty()) {
199            query = query.filter("event", event);
200        }
201        if let Some(kind) = filter.model_type.as_deref().filter(|m| !m.is_empty()) {
202            query = query.filter("model_type", kind);
203        }
204        if let Some(from) = filter.from.as_deref().filter(|d| !d.is_empty()) {
205            query = query.filter_op("created_at", ">=", format!("{from} 00:00:00"));
206        }
207        if let Some(to) = filter.to.as_deref().filter(|d| !d.is_empty()) {
208            // The whole of the closing day. `<= "2026-09-03"` would compare
209            // against midnight and drop everything that happened that day,
210            // which is the day somebody filtering for it most wants.
211            query = query.filter_op("created_at", "<=", format!("{to} 23:59:59"));
212        }
213        if let Some(ip) = filter.ip_address.as_deref().filter(|i| !i.is_empty()) {
214            query = query.filter("ip_address", ip);
215        }
216        if let Some(search) = filter.search.as_deref().filter(|s| !s.is_empty()) {
217            let pattern = format!("%{}%", escape_like(search));
218            // Parenthesised, and that matters: without the group an OR would
219            // reach outside and quietly widen every other filter on the bar.
220            query = query.group_filter(|group| {
221                group
222                    .filter_like("description", pattern.clone())
223                    .or_filter_like("event", pattern.clone())
224                    .or_filter_like("user_name", pattern.clone())
225            });
226        }
227
228        query
229    }
230}
231
232fn optional(value: Option<String>) -> Value {
233    value.filter(|v| !v.is_empty()).map_or(Value::Null, Value::from)
234}
235
236/// `%` and `_` are wildcards in LIKE, so a search for `50%` must not become a
237/// search for "50 followed by anything".
238fn escape_like(text: &str) -> String {
239    text.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
240}
241
242fn read(row: &rustlavel_db::Row) -> Entry {
243    Entry {
244        id: row.get("id").unwrap_or_default(),
245        user_id: row.get::<i64>("user_id").ok(),
246        user_name: row.get::<String>("user_name").ok().filter(|n| !n.is_empty()),
247        event: row.get("event").unwrap_or_default(),
248        model_type: row.get::<String>("model_type").ok().filter(|n| !n.is_empty()),
249        model_id: row.get::<String>("model_id").ok().filter(|n| !n.is_empty()),
250        description: row.get::<String>("description").ok().filter(|n| !n.is_empty()),
251        properties: row
252            .get::<String>("properties")
253            .ok()
254            .filter(|p| !p.is_empty())
255            .and_then(|p| Json::parse(&p).ok())
256            .unwrap_or(Json::Null),
257        ip_address: row.get::<String>("ip_address").ok().filter(|n| !n.is_empty()),
258        user_agent: row.get::<String>("user_agent").ok().filter(|n| !n.is_empty()),
259        created_at: row.get("created_at").unwrap_or_default(),
260    }
261}
262
263/// `YYYY-MM-DD HH:MM:SS` in UTC.
264pub fn now() -> String {
265    format_utc(
266        std::time::SystemTime::now()
267            .duration_since(std::time::UNIX_EPOCH)
268            .map_or(0, |d| d.as_secs() as i64),
269    )
270}
271
272/// Howard Hinnant's civil calendar, the same one the HTTP date code uses.
273pub fn format_utc(unix: i64) -> String {
274    let days = unix.div_euclid(86_400);
275    let seconds = unix.rem_euclid(86_400);
276
277    let z = days + 719_468;
278    let era = z.div_euclid(146_097);
279    let doe = z.rem_euclid(146_097);
280    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
281    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
282    let mp = (5 * doy + 2) / 153;
283    let day = doy - (153 * mp + 2) / 5 + 1;
284    let month = if mp < 10 { mp + 3 } else { mp - 9 };
285    let year = yoe + era * 400 + i64::from(month <= 2);
286
287    format!(
288        "{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}",
289        seconds / 3600,
290        (seconds % 3600) / 60,
291        seconds % 60
292    )
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn a_page_reports_its_own_bounds() {
301        let page = Page { entries: vec![Entry::new("a"); 50], total: 305, page: 1, per_page: 50 };
302
303        assert_eq!(page.pages(), 7);
304        assert_eq!(page.first(), 1);
305        assert_eq!(page.last(), 50);
306
307        let page = Page { entries: vec![Entry::new("a"); 5], total: 305, page: 7, per_page: 50 };
308        assert_eq!(page.first(), 301);
309        assert_eq!(page.last(), 305);
310
311        // An empty trail says "0 of 0" rather than "1 of 0".
312        let page = Page { entries: vec![], total: 0, page: 1, per_page: 50 };
313        assert_eq!(page.first(), 0);
314        assert_eq!(page.last(), 0);
315        assert_eq!(page.pages(), 1);
316    }
317
318    #[test]
319    fn a_search_cannot_smuggle_wildcards_in() {
320        assert_eq!(escape_like("50%"), "50\\%");
321        assert_eq!(escape_like("a_b"), "a\\_b");
322        assert_eq!(escape_like("100\\%"), "100\\\\\\%");
323    }
324
325    #[test]
326    fn an_entry_without_a_description_still_says_something() {
327        let mut entry = Entry::new("users.deleted");
328        assert_eq!(entry.summary(), "Somebody performed users.deleted");
329
330        entry.user_name = Some("Ada Lovelace".into());
331        entry.model_type = Some("User".into());
332        entry.model_id = Some("7".into());
333        assert_eq!(entry.summary(), "Ada Lovelace performed users.deleted on User #7");
334
335        entry.description = Some("Ada Lovelace deleted Alan Turing".into());
336        assert_eq!(entry.summary(), "Ada Lovelace deleted Alan Turing");
337    }
338
339    #[test]
340    fn the_epoch_formats_the_way_the_rest_of_the_framework_stores_a_time() {
341        assert_eq!(format_utc(0), "1970-01-01 00:00:00");
342        assert_eq!(format_utc(1_788_400_000), "2026-09-03 01:46:40");
343    }
344}