1use axum::extract::{Path, Query, State};
8use axum::http::StatusCode;
9use axum::routing::get;
10use axum::{Json, Router};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use uuid::Uuid;
15
16use crate::auth::{self, Principal};
17use crate::error::{AppError, AppResult};
18use crate::models::{
19 PersistedSnapshot, SnapshotSchedule, SnapshotScheduleCreate, SnapshotScheduleUpdate,
20};
21use crate::routes::cameras::load_camera;
22use crate::state::AppState;
23use crate::util;
24
25pub fn router() -> Router<AppState> {
26 Router::new()
27 .route(
28 "/api/v1/cameras/{id}/snapshot-schedules",
29 get(list_schedules).post(create_schedule),
30 )
31 .route(
32 "/api/v1/snapshot-schedules/{schedule_id}",
33 axum::routing::patch(update_schedule).delete(delete_schedule),
34 )
35 .route("/api/v1/cameras/{id}/snapshots", get(list_snapshots))
36}
37
38fn clamp_interval(seconds: i64) -> i64 {
40 seconds.clamp(5, 86_400)
41}
42
43async fn list_schedules(
44 State(st): State<AppState>,
45 principal: Principal,
46 Path(id): Path<String>,
47) -> AppResult<Json<Vec<SnapshotSchedule>>> {
48 principal.require(principal.can_view(), "list snapshot schedules")?;
49 let _ = load_camera(&st.pool, &id).await?;
50 let rows = sqlx::query_as::<_, SnapshotSchedule>(
51 "SELECT * FROM snapshot_schedules WHERE camera_id = ? ORDER BY created_at ASC",
52 )
53 .bind(&id)
54 .fetch_all(&st.pool)
55 .await?;
56 Ok(Json(rows))
57}
58
59async fn create_schedule(
60 State(st): State<AppState>,
61 Path(id): Path<String>,
62 principal: Principal,
63 Json(body): Json<SnapshotScheduleCreate>,
64) -> AppResult<(StatusCode, Json<SnapshotSchedule>)> {
65 principal.require(principal.can_manage_registry(), "create snapshot schedules")?;
66 let _ = load_camera(&st.pool, &id).await?;
67
68 let interval = clamp_interval(body.interval_seconds.unwrap_or(300));
69 let enabled = body.enabled.unwrap_or(true);
70 let now = Utc::now();
71 let schedule_id = format!("snsch_{}", Uuid::new_v4().simple());
72
73 sqlx::query(
74 "INSERT INTO snapshot_schedules
75 (id, camera_id, interval_seconds, enabled, last_fired_at, created_at, updated_at)
76 VALUES (?, ?, ?, ?, NULL, ?, ?)",
77 )
78 .bind(&schedule_id)
79 .bind(&id)
80 .bind(interval)
81 .bind(enabled)
82 .bind(now)
83 .bind(now)
84 .execute(&st.pool)
85 .await?;
86
87 let schedule =
88 sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
89 .bind(&schedule_id)
90 .fetch_one(&st.pool)
91 .await?;
92 auth::audit(
93 &st.pool,
94 &principal,
95 "create_snapshot_schedule",
96 "snapshot_schedule",
97 &schedule_id,
98 json!({ "camera_id": &id, "interval_seconds": interval, "enabled": enabled }),
99 )
100 .await;
101 Ok((StatusCode::CREATED, Json(schedule)))
102}
103
104async fn update_schedule(
105 State(st): State<AppState>,
106 Path(schedule_id): Path<String>,
107 principal: Principal,
108 Json(body): Json<SnapshotScheduleUpdate>,
109) -> AppResult<Json<SnapshotSchedule>> {
110 principal.require(principal.can_manage_registry(), "update snapshot schedules")?;
111 let cur =
112 sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
113 .bind(&schedule_id)
114 .fetch_optional(&st.pool)
115 .await?
116 .ok_or_else(|| {
117 AppError::NotFound(format!("snapshot schedule {schedule_id} not found"))
118 })?;
119
120 let interval = clamp_interval(body.interval_seconds.unwrap_or(cur.interval_seconds));
121 let enabled = body.enabled.unwrap_or(cur.enabled);
122
123 sqlx::query(
124 "UPDATE snapshot_schedules SET interval_seconds = ?, enabled = ?, updated_at = ? WHERE id = ?",
125 )
126 .bind(interval)
127 .bind(enabled)
128 .bind(Utc::now())
129 .bind(&schedule_id)
130 .execute(&st.pool)
131 .await?;
132
133 let schedule =
134 sqlx::query_as::<_, SnapshotSchedule>("SELECT * FROM snapshot_schedules WHERE id = ?")
135 .bind(&schedule_id)
136 .fetch_one(&st.pool)
137 .await?;
138 auth::audit(
139 &st.pool,
140 &principal,
141 "update_snapshot_schedule",
142 "snapshot_schedule",
143 &schedule_id,
144 json!({ "interval_seconds": interval, "enabled": enabled }),
145 )
146 .await;
147 Ok(Json(schedule))
148}
149
150async fn delete_schedule(
151 State(st): State<AppState>,
152 Path(schedule_id): Path<String>,
153 principal: Principal,
154) -> AppResult<StatusCode> {
155 principal.require(principal.can_manage_registry(), "delete snapshot schedules")?;
156 let res = sqlx::query("DELETE FROM snapshot_schedules WHERE id = ?")
157 .bind(&schedule_id)
158 .execute(&st.pool)
159 .await?;
160 if res.rows_affected() == 0 {
161 return Err(AppError::NotFound(format!(
162 "snapshot schedule {schedule_id} not found"
163 )));
164 }
165 auth::audit(
166 &st.pool,
167 &principal,
168 "delete_snapshot_schedule",
169 "snapshot_schedule",
170 &schedule_id,
171 json!({}),
172 )
173 .await;
174 Ok(StatusCode::NO_CONTENT)
175}
176
177#[derive(Debug, Deserialize)]
178struct SnapshotRangeQuery {
179 from: Option<String>,
180 to: Option<String>,
181 limit: Option<i64>,
182}
183
184#[derive(Debug, Serialize)]
188pub struct SnapshotView {
189 #[serde(flatten)]
190 snap: PersistedSnapshot,
191 url: String,
193}
194
195impl SnapshotView {
196 fn new(snap: PersistedSnapshot) -> Self {
197 let file = std::path::Path::new(&snap.path)
198 .file_name()
199 .and_then(|s| s.to_str())
200 .unwrap_or("");
201 let url = format!("/media/snapshots/{}/{}", snap.camera_id, file);
202 SnapshotView { snap, url }
203 }
204}
205
206async fn list_snapshots(
207 State(st): State<AppState>,
208 principal: Principal,
209 Path(id): Path<String>,
210 Query(q): Query<SnapshotRangeQuery>,
211) -> AppResult<Json<Vec<SnapshotView>>> {
212 principal.require(principal.can_view(), "list snapshots")?;
213 let _ = load_camera(&st.pool, &id).await?;
214 let limit = q.limit.unwrap_or(500).clamp(1, 5000);
215 let parse = |s: &Option<String>, field: &str| -> AppResult<Option<DateTime<Utc>>> {
216 match s {
217 Some(v) => util::parse_rfc3339(v)
218 .map(Some)
219 .ok_or_else(|| AppError::BadRequest(format!("invalid `{field}` timestamp"))),
220 None => Ok(None),
221 }
222 };
223 let from = parse(&q.from, "from")?;
224 let to = parse(&q.to, "to")?;
225 if let (Some(f), Some(t)) = (from, to) {
226 if f > t {
227 return Err(AppError::BadRequest("`from` must be <= `to`".into()));
228 }
229 }
230
231 let rows = sqlx::query_as::<_, PersistedSnapshot>(
232 "SELECT * FROM snapshots
233 WHERE camera_id = ?
234 AND (? IS NULL OR taken_at >= ?)
235 AND (? IS NULL OR taken_at <= ?)
236 ORDER BY taken_at DESC LIMIT ?",
237 )
238 .bind(&id)
239 .bind(from)
240 .bind(from)
241 .bind(to)
242 .bind(to)
243 .bind(limit)
244 .fetch_all(&st.pool)
245 .await?;
246
247 let views = rows.into_iter().map(SnapshotView::new).collect();
248 Ok(Json(views))
249}