1use crate::db::Database;
2use anyhow::Result;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5
6#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8pub enum AuditAction {
9 Create,
10 Update,
11 Delete,
12 Publish,
13 Unpublish,
14 Schedule,
15 Restore,
16 Login,
17 LoginFailed,
18 Logout,
19 UserCreate,
20 UserUpdate,
21 UserDelete,
22 RoleChange,
23 PasswordChange,
24 Upload,
25 MediaDelete,
26 TagCreate,
27 TagDelete,
28 SettingsUpdate,
29 Cleanup,
30 Export,
31}
32
33impl AuditAction {
34 pub fn as_str(&self) -> &'static str {
35 match self {
36 Self::Create => "create",
37 Self::Update => "update",
38 Self::Delete => "delete",
39 Self::Publish => "publish",
40 Self::Unpublish => "unpublish",
41 Self::Schedule => "schedule",
42 Self::Restore => "restore",
43 Self::Login => "login",
44 Self::LoginFailed => "login_failed",
45 Self::Logout => "logout",
46 Self::UserCreate => "user_create",
47 Self::UserUpdate => "user_update",
48 Self::UserDelete => "user_delete",
49 Self::RoleChange => "role_change",
50 Self::PasswordChange => "password_change",
51 Self::Upload => "upload",
52 Self::MediaDelete => "media_delete",
53 Self::TagCreate => "tag_create",
54 Self::TagDelete => "tag_delete",
55 Self::SettingsUpdate => "settings_update",
56 Self::Cleanup => "cleanup",
57 Self::Export => "export",
58 }
59 }
60
61 pub fn from_str(s: &str) -> Option<Self> {
62 match s {
63 "create" => Some(Self::Create),
64 "update" => Some(Self::Update),
65 "delete" => Some(Self::Delete),
66 "publish" => Some(Self::Publish),
67 "unpublish" => Some(Self::Unpublish),
68 "schedule" => Some(Self::Schedule),
69 "restore" => Some(Self::Restore),
70 "login" => Some(Self::Login),
71 "login_failed" => Some(Self::LoginFailed),
72 "logout" => Some(Self::Logout),
73 "user_create" => Some(Self::UserCreate),
74 "user_update" => Some(Self::UserUpdate),
75 "user_delete" => Some(Self::UserDelete),
76 "role_change" => Some(Self::RoleChange),
77 "password_change" => Some(Self::PasswordChange),
78 "upload" => Some(Self::Upload),
79 "media_delete" => Some(Self::MediaDelete),
80 "tag_create" => Some(Self::TagCreate),
81 "tag_delete" => Some(Self::TagDelete),
82 "settings_update" => Some(Self::SettingsUpdate),
83 "cleanup" => Some(Self::Cleanup),
84 "export" => Some(Self::Export),
85 _ => None,
86 }
87 }
88
89 pub fn display_name(&self) -> &'static str {
90 match self {
91 Self::Create => "Create",
92 Self::Update => "Update",
93 Self::Delete => "Delete",
94 Self::Publish => "Publish",
95 Self::Unpublish => "Unpublish",
96 Self::Schedule => "Schedule",
97 Self::Restore => "Restore",
98 Self::Login => "Login",
99 Self::LoginFailed => "Login Failed",
100 Self::Logout => "Logout",
101 Self::UserCreate => "User Create",
102 Self::UserUpdate => "User Update",
103 Self::UserDelete => "User Delete",
104 Self::RoleChange => "Role Change",
105 Self::PasswordChange => "Password Change",
106 Self::Upload => "Upload",
107 Self::MediaDelete => "Media Delete",
108 Self::TagCreate => "Tag Create",
109 Self::TagDelete => "Tag Delete",
110 Self::SettingsUpdate => "Settings Update",
111 Self::Cleanup => "Cleanup",
112 Self::Export => "Export",
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(rename_all = "snake_case")]
119pub enum AuditCategory {
120 Content,
121 Auth,
122 User,
123 Media,
124 Tag,
125 Settings,
126 System,
127}
128
129impl AuditCategory {
130 pub fn as_str(&self) -> &'static str {
131 match self {
132 Self::Content => "content",
133 Self::Auth => "auth",
134 Self::User => "user",
135 Self::Media => "media",
136 Self::Tag => "tag",
137 Self::Settings => "settings",
138 Self::System => "system",
139 }
140 }
141
142 pub fn from_str(s: &str) -> Option<Self> {
143 match s {
144 "content" => Some(Self::Content),
145 "auth" => Some(Self::Auth),
146 "user" => Some(Self::User),
147 "media" => Some(Self::Media),
148 "tag" => Some(Self::Tag),
149 "settings" => Some(Self::Settings),
150 "system" => Some(Self::System),
151 _ => None,
152 }
153 }
154
155 pub fn display_name(&self) -> &'static str {
156 match self {
157 Self::Content => "Content",
158 Self::Auth => "Authentication",
159 Self::User => "User Management",
160 Self::Media => "Media",
161 Self::Tag => "Tags",
162 Self::Settings => "Settings",
163 Self::System => "System",
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct AuditEntry {
170 pub id: i64,
171 pub timestamp: String,
172 pub user_id: Option<i64>,
173 pub username: Option<String>,
174 pub user_role: Option<String>,
175 pub action: String,
176 pub category: String,
177 pub entity_type: Option<String>,
178 pub entity_id: Option<i64>,
179 pub entity_title: Option<String>,
180 pub ip_address: Option<String>,
181 pub user_agent: Option<String>,
182 pub status: String,
183 pub error_message: Option<String>,
184 pub changes: Option<serde_json::Value>,
185 pub metadata: serde_json::Value,
186}
187
188impl AuditEntry {
189 pub fn action_enum(&self) -> Option<AuditAction> {
190 AuditAction::from_str(&self.action)
191 }
192
193 pub fn category_enum(&self) -> Option<AuditCategory> {
194 AuditCategory::from_str(&self.category)
195 }
196
197 pub fn is_failure(&self) -> bool {
198 self.status == "failure"
199 }
200}
201
202#[derive(Debug, Clone, Default)]
203pub struct AuditContext {
204 pub user_id: Option<i64>,
205 pub username: Option<String>,
206 pub user_role: Option<String>,
207 pub ip_address: Option<String>,
208 pub user_agent: Option<String>,
209}
210
211impl AuditContext {
212 pub fn new() -> Self {
213 Self::default()
214 }
215
216 pub fn with_user(mut self, id: i64, username: &str, role: &str) -> Self {
217 self.user_id = Some(id);
218 self.username = Some(username.to_string());
219 self.user_role = Some(role.to_string());
220 self
221 }
222
223 pub fn with_request(mut self, ip: Option<String>, user_agent: Option<String>) -> Self {
224 self.ip_address = ip;
225 self.user_agent = user_agent;
226 self
227 }
228}
229
230#[derive(Debug, Clone)]
231pub struct AuditLogBuilder {
232 action: AuditAction,
233 category: AuditCategory,
234 entity_type: Option<String>,
235 entity_id: Option<i64>,
236 entity_title: Option<String>,
237 changes: Option<serde_json::Value>,
238 metadata: serde_json::Value,
239 status: String,
240 error_message: Option<String>,
241}
242
243impl AuditLogBuilder {
244 pub fn new(action: AuditAction, category: AuditCategory) -> Self {
245 Self {
246 action,
247 category,
248 entity_type: None,
249 entity_id: None,
250 entity_title: None,
251 changes: None,
252 metadata: json!({}),
253 status: "success".to_string(),
254 error_message: None,
255 }
256 }
257
258 pub fn entity(mut self, entity_type: &str, id: i64, title: Option<&str>) -> Self {
259 self.entity_type = Some(entity_type.to_string());
260 self.entity_id = Some(id);
261 self.entity_title = title.map(|s| s.to_string());
262 self
263 }
264
265 pub fn entity_type_only(mut self, entity_type: &str) -> Self {
266 self.entity_type = Some(entity_type.to_string());
267 self
268 }
269
270 pub fn changes(mut self, changes: serde_json::Value) -> Self {
271 self.changes = Some(changes);
272 self
273 }
274
275 pub fn metadata_value(mut self, key: &str, value: serde_json::Value) -> Self {
276 if let serde_json::Value::Object(ref mut map) = self.metadata {
277 map.insert(key.to_string(), value);
278 }
279 self
280 }
281
282 pub fn failure(mut self, error: &str) -> Self {
283 self.status = "failure".to_string();
284 self.error_message = Some(error.to_string());
285 self
286 }
287}
288
289#[derive(Debug, Clone, Serialize)]
290pub struct AuditSummary {
291 pub total_events: i64,
292 pub events_today: i64,
293 pub failed_events: i64,
294 pub active_users_today: i64,
295 pub recent_failures: Vec<AuditEntry>,
296 pub actions_breakdown: Vec<ActionCount>,
297}
298
299#[derive(Debug, Clone, Serialize)]
300pub struct ActionCount {
301 pub action: String,
302 pub count: i64,
303}
304
305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
306pub struct AuditFilter {
307 pub user_id: Option<i64>,
308 pub username: Option<String>,
309 pub action: Option<String>,
310 pub category: Option<String>,
311 pub entity_type: Option<String>,
312 pub status: Option<String>,
313 pub search: Option<String>,
314 pub from_date: Option<String>,
315 pub to_date: Option<String>,
316}
317
318pub fn log(db: &Database, ctx: &AuditContext, builder: AuditLogBuilder) -> Result<i64> {
319 let conn = db.get()?;
320
321 let changes_json = builder.changes.map(|c| c.to_string());
322 let metadata_json = builder.metadata.to_string();
323
324 conn.execute(
325 r#"
326 INSERT INTO audit_logs (
327 user_id, username, user_role, action, category,
328 entity_type, entity_id, entity_title,
329 ip_address, user_agent, status, error_message,
330 changes, metadata
331 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
332 "#,
333 rusqlite::params![
334 ctx.user_id,
335 ctx.username,
336 ctx.user_role,
337 builder.action.as_str(),
338 builder.category.as_str(),
339 builder.entity_type,
340 builder.entity_id,
341 builder.entity_title,
342 ctx.ip_address,
343 ctx.user_agent,
344 builder.status,
345 builder.error_message,
346 changes_json,
347 metadata_json,
348 ],
349 )?;
350
351 Ok(conn.last_insert_rowid())
352}
353
354pub fn list_logs(
355 db: &Database,
356 filter: &AuditFilter,
357 limit: usize,
358 offset: usize,
359) -> Result<Vec<AuditEntry>> {
360 let conn = db.get()?;
361
362 let (where_clause, params) = build_filter_clause(filter);
363
364 let sql = format!(
365 r#"
366 SELECT id, timestamp, user_id, username, user_role, action, category,
367 entity_type, entity_id, entity_title, ip_address, user_agent,
368 status, error_message, changes, metadata
369 FROM audit_logs
370 {}
371 ORDER BY timestamp DESC
372 LIMIT ?{} OFFSET ?{}
373 "#,
374 where_clause,
375 params.len() + 1,
376 params.len() + 2
377 );
378
379 let mut stmt = conn.prepare(&sql)?;
380
381 let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = params
382 .into_iter()
383 .map(|s| Box::new(s) as Box<dyn rusqlite::ToSql>)
384 .collect();
385 all_params.push(Box::new(limit as i64));
386 all_params.push(Box::new(offset as i64));
387
388 let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect();
389
390 let entries = stmt
391 .query_map(param_refs.as_slice(), |row| {
392 Ok(AuditEntry {
393 id: row.get(0)?,
394 timestamp: row.get(1)?,
395 user_id: row.get(2)?,
396 username: row.get(3)?,
397 user_role: row.get(4)?,
398 action: row.get(5)?,
399 category: row.get(6)?,
400 entity_type: row.get(7)?,
401 entity_id: row.get(8)?,
402 entity_title: row.get(9)?,
403 ip_address: row.get(10)?,
404 user_agent: row.get(11)?,
405 status: row.get(12)?,
406 error_message: row.get(13)?,
407 changes: row
408 .get::<_, Option<String>>(14)?
409 .and_then(|s| serde_json::from_str(&s).ok()),
410 metadata: row
411 .get::<_, String>(15)
412 .ok()
413 .and_then(|s| serde_json::from_str(&s).ok())
414 .unwrap_or(json!({})),
415 })
416 })?
417 .collect::<Result<Vec<_>, _>>()?;
418
419 Ok(entries)
420}
421
422pub fn count_logs(db: &Database, filter: &AuditFilter) -> Result<i64> {
423 let conn = db.get()?;
424
425 let (where_clause, params) = build_filter_clause(filter);
426
427 let sql = format!("SELECT COUNT(*) FROM audit_logs {}", where_clause);
428
429 let mut stmt = conn.prepare(&sql)?;
430 let param_refs: Vec<&dyn rusqlite::ToSql> =
431 params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
432
433 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
434
435 Ok(count)
436}
437
438pub fn get_log(db: &Database, id: i64) -> Result<Option<AuditEntry>> {
439 let conn = db.get()?;
440
441 let entry = conn.query_row(
442 r#"
443 SELECT id, timestamp, user_id, username, user_role, action, category,
444 entity_type, entity_id, entity_title, ip_address, user_agent,
445 status, error_message, changes, metadata
446 FROM audit_logs
447 WHERE id = ?1
448 "#,
449 [id],
450 |row| {
451 Ok(AuditEntry {
452 id: row.get(0)?,
453 timestamp: row.get(1)?,
454 user_id: row.get(2)?,
455 username: row.get(3)?,
456 user_role: row.get(4)?,
457 action: row.get(5)?,
458 category: row.get(6)?,
459 entity_type: row.get(7)?,
460 entity_id: row.get(8)?,
461 entity_title: row.get(9)?,
462 ip_address: row.get(10)?,
463 user_agent: row.get(11)?,
464 status: row.get(12)?,
465 error_message: row.get(13)?,
466 changes: row
467 .get::<_, Option<String>>(14)?
468 .and_then(|s| serde_json::from_str(&s).ok()),
469 metadata: row
470 .get::<_, String>(15)
471 .ok()
472 .and_then(|s| serde_json::from_str(&s).ok())
473 .unwrap_or(json!({})),
474 })
475 },
476 );
477
478 match entry {
479 Ok(e) => Ok(Some(e)),
480 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
481 Err(e) => Err(e.into()),
482 }
483}
484
485pub fn get_summary(db: &Database, days: u32) -> Result<AuditSummary> {
486 let conn = db.get()?;
487
488 let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
489 let cutoff_str = cutoff.format("%Y-%m-%dT%H:%M:%S").to_string();
490
491 let today_start = chrono::Utc::now().format("%Y-%m-%d").to_string();
492
493 let total_events: i64 =
494 conn.query_row("SELECT COUNT(*) FROM audit_logs", [], |row| row.get(0))?;
495
496 let events_today: i64 = conn.query_row(
497 "SELECT COUNT(*) FROM audit_logs WHERE timestamp >= ?1",
498 [&today_start],
499 |row| row.get(0),
500 )?;
501
502 let failed_events: i64 = conn.query_row(
503 "SELECT COUNT(*) FROM audit_logs WHERE status = 'failure' AND timestamp >= ?1",
504 [&cutoff_str],
505 |row| row.get(0),
506 )?;
507
508 let active_users_today: i64 = conn.query_row(
509 "SELECT COUNT(DISTINCT user_id) FROM audit_logs WHERE user_id IS NOT NULL AND timestamp >= ?1",
510 [&today_start],
511 |row| row.get(0),
512 )?;
513
514 let mut stmt = conn.prepare(
515 r#"
516 SELECT id, timestamp, user_id, username, user_role, action, category,
517 entity_type, entity_id, entity_title, ip_address, user_agent,
518 status, error_message, changes, metadata
519 FROM audit_logs
520 WHERE status = 'failure'
521 ORDER BY timestamp DESC
522 LIMIT 5
523 "#,
524 )?;
525
526 let recent_failures = stmt
527 .query_map([], |row| {
528 Ok(AuditEntry {
529 id: row.get(0)?,
530 timestamp: row.get(1)?,
531 user_id: row.get(2)?,
532 username: row.get(3)?,
533 user_role: row.get(4)?,
534 action: row.get(5)?,
535 category: row.get(6)?,
536 entity_type: row.get(7)?,
537 entity_id: row.get(8)?,
538 entity_title: row.get(9)?,
539 ip_address: row.get(10)?,
540 user_agent: row.get(11)?,
541 status: row.get(12)?,
542 error_message: row.get(13)?,
543 changes: row
544 .get::<_, Option<String>>(14)?
545 .and_then(|s| serde_json::from_str(&s).ok()),
546 metadata: row
547 .get::<_, String>(15)
548 .ok()
549 .and_then(|s| serde_json::from_str(&s).ok())
550 .unwrap_or(json!({})),
551 })
552 })?
553 .collect::<Result<Vec<_>, _>>()?;
554
555 let mut stmt = conn.prepare(
556 r#"
557 SELECT action, COUNT(*) as count
558 FROM audit_logs
559 WHERE timestamp >= ?1
560 GROUP BY action
561 ORDER BY count DESC
562 LIMIT 10
563 "#,
564 )?;
565
566 let actions_breakdown = stmt
567 .query_map([&cutoff_str], |row| {
568 Ok(ActionCount {
569 action: row.get(0)?,
570 count: row.get(1)?,
571 })
572 })?
573 .collect::<Result<Vec<_>, _>>()?;
574
575 Ok(AuditSummary {
576 total_events,
577 events_today,
578 failed_events,
579 active_users_today,
580 recent_failures,
581 actions_breakdown,
582 })
583}
584
585pub fn export_logs(db: &Database, filter: &AuditFilter, format: &str) -> Result<String> {
586 let logs = list_logs(db, filter, 10000, 0)?;
587
588 match format {
589 "csv" => {
590 let mut csv = String::from("timestamp,user,action,category,entity_type,entity_id,entity_title,status,ip_address\n");
591 for log in logs {
592 csv.push_str(&format!(
593 "{},{},{},{},{},{},{},{},{}\n",
594 log.timestamp,
595 log.username.unwrap_or_default(),
596 log.action,
597 log.category,
598 log.entity_type.unwrap_or_default(),
599 log.entity_id.map(|i| i.to_string()).unwrap_or_default(),
600 log.entity_title.unwrap_or_default().replace(',', ";"),
601 log.status,
602 log.ip_address.unwrap_or_default(),
603 ));
604 }
605 Ok(csv)
606 }
607 _ => Ok(serde_json::to_string_pretty(&logs)?),
608 }
609}
610
611pub fn cleanup_old_logs(db: &Database, retention_days: u32) -> Result<usize> {
612 if retention_days == 0 {
613 return Ok(0);
614 }
615
616 let conn = db.get()?;
617
618 let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
619 let cutoff_str = cutoff.format("%Y-%m-%dT%H:%M:%S").to_string();
620
621 let deleted = conn.execute("DELETE FROM audit_logs WHERE timestamp < ?1", [&cutoff_str])?;
622
623 if deleted > 0 {
624 tracing::info!("Cleaned up {} old audit log entries", deleted);
625 }
626
627 Ok(deleted)
628}
629
630pub fn get_audit_users(db: &Database) -> Result<Vec<(i64, String)>> {
631 let conn = db.get()?;
632
633 let mut stmt = conn.prepare(
634 r#"
635 SELECT DISTINCT user_id, username
636 FROM audit_logs
637 WHERE user_id IS NOT NULL AND username IS NOT NULL
638 ORDER BY username
639 "#,
640 )?;
641
642 let users = stmt
643 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
644 .collect::<Result<Vec<_>, _>>()?;
645
646 Ok(users)
647}
648
649fn build_filter_clause(filter: &AuditFilter) -> (String, Vec<String>) {
650 let mut conditions = Vec::new();
651 let mut params = Vec::new();
652
653 if let Some(ref user_id) = filter.user_id {
654 params.push(user_id.to_string());
655 conditions.push(format!("user_id = ?{}", params.len()));
656 }
657
658 if let Some(ref username) = filter.username {
659 params.push(username.clone());
660 conditions.push(format!("username = ?{}", params.len()));
661 }
662
663 if let Some(ref action) = filter.action {
664 params.push(action.clone());
665 conditions.push(format!("action = ?{}", params.len()));
666 }
667
668 if let Some(ref category) = filter.category {
669 params.push(category.clone());
670 conditions.push(format!("category = ?{}", params.len()));
671 }
672
673 if let Some(ref entity_type) = filter.entity_type {
674 params.push(entity_type.clone());
675 conditions.push(format!("entity_type = ?{}", params.len()));
676 }
677
678 if let Some(ref status) = filter.status {
679 params.push(status.clone());
680 conditions.push(format!("status = ?{}", params.len()));
681 }
682
683 if let Some(ref search) = filter.search {
684 let search_pattern = format!("%{}%", search);
685 params.push(search_pattern.clone());
686 params.push(search_pattern.clone());
687 params.push(search_pattern);
688 conditions.push(format!(
689 "(username LIKE ?{} OR entity_title LIKE ?{} OR action LIKE ?{})",
690 params.len() - 2,
691 params.len() - 1,
692 params.len()
693 ));
694 }
695
696 if let Some(ref from_date) = filter.from_date {
697 params.push(from_date.clone());
698 conditions.push(format!("timestamp >= ?{}", params.len()));
699 }
700
701 if let Some(ref to_date) = filter.to_date {
702 params.push(format!("{}T23:59:59", to_date));
703 conditions.push(format!("timestamp <= ?{}", params.len()));
704 }
705
706 let where_clause = if conditions.is_empty() {
707 String::new()
708 } else {
709 format!("WHERE {}", conditions.join(" AND "))
710 };
711
712 (where_clause, params)
713}
714
715pub fn get_all_actions() -> Vec<(&'static str, &'static str)> {
716 vec![
717 ("create", "Create"),
718 ("update", "Update"),
719 ("delete", "Delete"),
720 ("publish", "Publish"),
721 ("unpublish", "Unpublish"),
722 ("schedule", "Schedule"),
723 ("restore", "Restore"),
724 ("login", "Login"),
725 ("login_failed", "Login Failed"),
726 ("logout", "Logout"),
727 ("user_create", "User Create"),
728 ("user_update", "User Update"),
729 ("user_delete", "User Delete"),
730 ("role_change", "Role Change"),
731 ("password_change", "Password Change"),
732 ("upload", "Upload"),
733 ("media_delete", "Media Delete"),
734 ("tag_create", "Tag Create"),
735 ("tag_delete", "Tag Delete"),
736 ("settings_update", "Settings Update"),
737 ("cleanup", "Cleanup"),
738 ("export", "Export"),
739 ]
740}
741
742pub fn get_all_categories() -> Vec<(&'static str, &'static str)> {
743 vec![
744 ("content", "Content"),
745 ("auth", "Authentication"),
746 ("user", "User Management"),
747 ("media", "Media"),
748 ("tag", "Tags"),
749 ("settings", "Settings"),
750 ("system", "System"),
751 ]
752}